Repository: remorses/bundless
Branch: master
Commit: c7c167a78b03
Files: 422
Total size: 1.0 MB
Directory structure:
gitextract_cw0f3mmf/
├── .changeset/
│ ├── README.md
│ └── config.json
├── .github/
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── .prettierrc
├── .vscode/
│ └── settings.json
├── README.md
├── TODOS.md
├── bundless/
│ ├── CHANGELOG.md
│ ├── bin.js
│ ├── package.json
│ ├── src/
│ │ ├── build/
│ │ │ └── index.ts
│ │ ├── cli.ts
│ │ ├── client/
│ │ │ ├── template.ts
│ │ │ └── types.ts
│ │ ├── config.ts
│ │ ├── constants.ts
│ │ ├── hmr-graph.ts
│ │ ├── index.ts
│ │ ├── logger.ts
│ │ ├── middleware/
│ │ │ ├── history-fallback.ts
│ │ │ ├── index.ts
│ │ │ ├── open-in-editor.ts
│ │ │ ├── plugins.ts
│ │ │ ├── sourcemap.ts
│ │ │ └── static-serve.ts
│ │ ├── plugins/
│ │ │ ├── assets.ts
│ │ │ ├── buffer.ts
│ │ │ ├── css.ts
│ │ │ ├── env.ts
│ │ │ ├── esbuild.ts
│ │ │ ├── hmr-client.ts
│ │ │ ├── html-ingest.ts
│ │ │ ├── html-resolver.ts
│ │ │ ├── html-transform.ts
│ │ │ ├── index.ts
│ │ │ ├── json.ts
│ │ │ ├── resolve-sourcemaps.ts
│ │ │ ├── rewrite/
│ │ │ │ ├── __snapshots__/
│ │ │ │ │ └── commonjs.test.ts.snap
│ │ │ │ ├── commonjs.test.ts
│ │ │ │ ├── commonjs.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── rewrite.ts
│ │ │ ├── source-map-support.ts
│ │ │ └── url-resolver.ts
│ │ ├── plugins-executor.ts
│ │ ├── prebundle/
│ │ │ ├── __snapshots__/
│ │ │ │ └── prebundle.test.ts.snap
│ │ │ ├── esbuild.ts
│ │ │ ├── index.ts
│ │ │ ├── prebundle.test.ts
│ │ │ ├── prebundle.ts
│ │ │ ├── stats.ts
│ │ │ ├── support.ts
│ │ │ └── traverse.ts
│ │ ├── serve.ts
│ │ └── utils/
│ │ ├── index.ts
│ │ ├── path.test.ts
│ │ ├── path.ts
│ │ ├── profiling.test.ts
│ │ ├── profiling.ts
│ │ ├── sourcemaps.ts
│ │ └── utils.ts
│ └── tsconfig.json
├── examples/
│ ├── react-javascript/
│ │ ├── .gitignore
│ │ ├── bundless.config.js
│ │ ├── package.json
│ │ ├── public/
│ │ │ └── index.html
│ │ └── src/
│ │ ├── app.jsx
│ │ ├── index.jsx
│ │ └── styles.css
│ ├── react-typescript/
│ │ ├── .gitignore
│ │ ├── bundless.config.js
│ │ ├── package.json
│ │ ├── public/
│ │ │ └── index.html
│ │ ├── src/
│ │ │ ├── app.tsx
│ │ │ ├── index.tsx
│ │ │ └── styles.css
│ │ └── tsconfig.json
│ ├── svelte/
│ │ ├── .gitignore
│ │ ├── bundless.config.js
│ │ ├── package.json
│ │ ├── public/
│ │ │ └── index.html
│ │ ├── scripts/
│ │ │ └── setupTypeScript.js
│ │ └── src/
│ │ ├── App.svelte
│ │ ├── global.css
│ │ └── main.js
│ └── vanilla-javascript/
│ ├── .gitignore
│ ├── bundless.config.js
│ ├── package.json
│ ├── public/
│ │ └── index.html
│ └── src/
│ ├── index.js
│ └── styles.css
├── fixtures/
│ ├── html-page/
│ │ ├── __mirror__/
│ │ │ └── index.html
│ │ ├── __snapshots__
│ │ └── index.html
│ ├── outsider.js
│ ├── resolve-sourcemap/
│ │ ├── __mirror__/
│ │ │ ├── folder/
│ │ │ │ └── main.js
│ │ │ └── index.html
│ │ ├── __snapshots__
│ │ ├── folder/
│ │ │ └── main.js
│ │ └── index.html
│ ├── serve-outside-root/
│ │ ├── __mirror__/
│ │ │ ├── __..__/
│ │ │ │ └── outsider.js
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ └── main.js
│ ├── simple-js/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ └── main.js
│ ├── with-alias-plugin/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ ├── main.tsx
│ │ │ └── text.ts
│ │ ├── __snapshots__
│ │ ├── bundless.config.js
│ │ ├── index.html
│ │ ├── main.tsx
│ │ ├── package.json
│ │ └── text.ts
│ ├── with-assets-imports/
│ │ ├── __mirror__/
│ │ │ ├── dynamic-import.js
│ │ │ ├── file.css.cssjs
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── bundless.config.js
│ │ ├── dynamic-import.js
│ │ ├── file.css
│ │ ├── index.html
│ │ └── main.js
│ ├── with-babel-plugin/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ └── main.tsx
│ │ ├── __snapshots__
│ │ ├── bundless.config.js
│ │ ├── index.html
│ │ ├── main.tsx
│ │ └── package.json
│ ├── with-commonjs-transform/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ └── main.jsx
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── main.jsx
│ │ └── package.json
│ ├── with-css/
│ │ ├── __mirror__/
│ │ │ ├── file.css.cssjs
│ │ │ ├── file.js
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── file.css
│ │ ├── file.js
│ │ ├── index.html
│ │ └── main.js
│ ├── with-css-modules/
│ │ ├── __mirror__/
│ │ │ ├── file.js
│ │ │ ├── file.module.css.cssjs
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── file.js
│ │ ├── file.module.css
│ │ ├── index.html
│ │ └── main.js
│ ├── with-custom-assets/
│ │ ├── __mirror__/
│ │ │ ├── file.fakecss.cssjs
│ │ │ ├── file.fakejs
│ │ │ ├── file.js
│ │ │ ├── index.html
│ │ │ ├── main.js
│ │ │ └── x.DAC
│ │ ├── __snapshots__
│ │ ├── bundless.config.js
│ │ ├── file.fakecss
│ │ ├── file.fakejs
│ │ ├── file.js
│ │ ├── index.html
│ │ ├── main.js
│ │ └── x.DAC
│ ├── with-dependencies/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ └── main.js
│ ├── with-dependencies-assets/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── main.js
│ │ └── package.json
│ ├── with-dynamic-import/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ ├── main.js
│ │ │ └── text.js
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── main.js
│ │ └── text.js
│ ├── with-env-plugin/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ └── main.tsx
│ │ ├── __snapshots__
│ │ ├── bundless.config.js
│ │ ├── envfile
│ │ ├── index.html
│ │ └── main.tsx
│ ├── with-esbuild-plugins/
│ │ ├── __mirror__/
│ │ │ ├── fake.js
│ │ │ ├── file.gql
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── bundless.config.js
│ │ ├── fake.js
│ │ ├── file.gql
│ │ ├── index.html
│ │ └── main.js
│ ├── with-imports/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ ├── main.js
│ │ │ └── text.js
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── main.js
│ │ └── text.js
│ ├── with-json/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ ├── main.js
│ │ │ └── text.json
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── main.js
│ │ └── text.json
│ ├── with-linked-workspace/
│ │ ├── __mirror__/
│ │ │ ├── __..__/
│ │ │ │ └── with-many-dependencies/
│ │ │ │ └── main.js
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── main.js
│ │ └── package.json
│ ├── with-links/
│ │ ├── __mirror__/
│ │ │ └── index.html
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── public/
│ │ │ ├── manifest.json
│ │ │ └── styles1.css
│ │ └── styles2.css
│ ├── with-many-dependencies/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── bundless.config.js
│ │ ├── index.html
│ │ ├── main.js
│ │ └── package.json
│ ├── with-many-entries/
│ │ ├── __mirror__/
│ │ │ ├── a/
│ │ │ │ ├── index.html
│ │ │ │ ├── main.css.cssjs
│ │ │ │ └── main.js
│ │ │ ├── b/
│ │ │ │ ├── index.html
│ │ │ │ ├── main.js
│ │ │ │ └── text.js
│ │ │ └── common.css.cssjs
│ │ ├── __snapshots__
│ │ ├── a/
│ │ │ ├── index.html
│ │ │ ├── main.css
│ │ │ └── main.js
│ │ ├── b/
│ │ │ ├── index.html
│ │ │ ├── main.js
│ │ │ └── text.js
│ │ ├── bundless.config.js
│ │ └── common.css
│ ├── with-node-polyfills/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ ├── main.js
│ │ │ └── path
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ └── main.js
│ ├── with-sourcemaps/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ ├── js.js
│ │ │ ├── main.ts
│ │ │ └── text.js
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── js.js
│ │ ├── main.ts
│ │ ├── package.json
│ │ └── text.js
│ ├── with-svelte/
│ │ ├── App.svelte
│ │ ├── __mirror__/
│ │ │ ├── App.svelte
│ │ │ ├── App.svelte.css
│ │ │ ├── index.html
│ │ │ └── main.js
│ │ ├── __snapshots__
│ │ ├── bundless.config.js
│ │ ├── index.html
│ │ ├── main.js
│ │ └── package.json
│ ├── with-tsconfig-paths/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ ├── main.tsx
│ │ │ └── text.ts
│ │ ├── __snapshots__
│ │ ├── bundless.config.js
│ │ ├── index.html
│ │ ├── main.tsx
│ │ ├── package.json
│ │ └── text.ts
│ ├── with-tsx/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ ├── main.tsx
│ │ │ ├── text.ts
│ │ │ └── utils.ts
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── main.tsx
│ │ ├── text.ts
│ │ └── utils.ts
│ ├── with-typescript/
│ │ ├── __mirror__/
│ │ │ ├── index.html
│ │ │ ├── main.ts
│ │ │ ├── text.ts
│ │ │ └── utils.ts
│ │ ├── __snapshots__
│ │ ├── index.html
│ │ ├── main.ts
│ │ ├── text.ts
│ │ └── utils.ts
│ └── with-yarn-berry-paths/
│ ├── __mirror__/
│ │ ├── index.html
│ │ └── main.js
│ ├── __snapshots__
│ ├── index.html
│ └── main.js
├── hmr-test-app/
│ ├── __snapshots__/
│ │ ├── bundless
│ │ ├── snowpack
│ │ └── vite
│ ├── bundless.config.js
│ ├── index.test.ts
│ ├── package.json
│ ├── public/
│ │ ├── bundless/
│ │ │ └── index.html
│ │ ├── index.html
│ │ ├── snowpack/
│ │ │ └── index.html
│ │ └── vite/
│ │ └── index.html
│ ├── snowpack.config.js
│ ├── src/
│ │ ├── bridge.jsx
│ │ ├── file.css
│ │ ├── file.json
│ │ ├── file.jsx
│ │ ├── file.module.css
│ │ ├── file2.js
│ │ ├── imported-many-times.js
│ │ └── main.jsx
│ ├── tsconfig.json
│ └── vite.config.js
├── jest.config.js
├── package.json
├── paged/
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src/
│ │ ├── client/
│ │ │ ├── context.ts
│ │ │ └── index.ts
│ │ ├── constants.ts
│ │ ├── export.tsx
│ │ ├── index.tsx
│ │ ├── plugin.tsx
│ │ ├── routes.ts
│ │ └── server.tsx
│ └── tsconfig.json
├── plugins/
│ ├── alias/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── babel/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── react-refresh/
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── svelte/
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ └── typescript.ts
│ │ ├── tsconfig.json
│ │ └── yarn-error.log
│ └── tsconfig-paths/
│ ├── package.json
│ ├── src/
│ │ └── index.ts
│ └── tsconfig.json
├── scripts/
│ ├── analyze.ts
│ ├── index.html
│ ├── partition.ts
│ ├── scc.ts
│ ├── topological.ts
│ ├── tsconfig.json
│ └── ws.ts
├── tests/
│ ├── CHANGELOG.md
│ ├── fixtures.test.ts
│ ├── package.json
│ └── utils.ts
├── tsconfig.base.json
├── website/
│ ├── .gitignore
│ ├── components/
│ │ └── GradientBg.tsx
│ ├── constants.ts
│ ├── next-env.d.ts
│ ├── next.config.js
│ ├── package.json
│ ├── pages/
│ │ ├── _app.tsx
│ │ ├── docs/
│ │ │ ├── benchmarks.mdx
│ │ │ ├── cli.mdx
│ │ │ ├── config.mdx
│ │ │ ├── faq.mdx
│ │ │ ├── how-it-works.mdx
│ │ │ ├── index.mdx
│ │ │ ├── integrations/
│ │ │ │ ├── alias.mdx
│ │ │ │ ├── babel.mdx
│ │ │ │ └── react-refresh.mdx
│ │ │ └── migration.mdx
│ │ └── index.tsx
│ └── tsconfig.json
└── with-pages/
├── CHANGELOG.md
├── components.tsx
├── export.js
├── index.test.ts
├── package.json
├── pages/
│ ├── about.tsx
│ ├── dynamic-import.tsx
│ ├── folder/
│ │ ├── about.tsx
│ │ └── index.tsx
│ ├── index.tsx
│ └── slugs/
│ ├── [slug].tsx
│ └── all/
│ └── [...slugs].tsx
├── rpc/
│ └── example.ts
└── server.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .changeset/README.md
================================================
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md)
================================================
FILE: .changeset/config.json
================================================
{
"$schema": "https://unpkg.com/@changesets/config@1.4.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [],
"access": "public",
"baseBranch": "master",
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
},
"updateInternalDependencies": "patch"
}
================================================
FILE: .github/workflows/ci.yml
================================================
name: Npm Package
on:
push:
branches:
- master
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os:
- macos-latest
- windows-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: actions/setup-node@v1
with:
node-version: 12
registry-url: https://registry.npmjs.org/
# caching
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-npm-
restore-keys: |
${{ runner.os }}-npm-
# scripts
- run: yarn
- run: yarn ultra --rebuild -r --filter '@bundless/*' build
- run: yarn test tests && yarn test ./bundless/src
- run: git diff
# - name: Create Release
# id: changesets
# uses: changesets/action@master
# with:
# publish: yarn release
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
================================================
FILE: .gitignore
================================================
node_modules
dist
esm
.DS_Store
*.tsbuildinfo
.ultra.cache.json
**/web_modules/deps_hash
**/web_modules/**/**.js
**/web_modules/**/**.js.map
out
temp
fixtures/with-pages/node_dist
*_out
with-pages/web_modules
with-pages/out
*_dist
_hmr_client.js
.bundless
scripts/metafile.json
================================================
FILE: .prettierrc
================================================
{
"arrowParens": "always",
"jsxSingleQuote": true,
"tabWidth": 4,
"semi": false,
"singleQuote": true,
"trailingComma": "all"
}
================================================
FILE: .vscode/settings.json
================================================
{
"todo-tree.filtering.excludeGlobs": [
"**/web_modules/**",
"**/web_modules/**",
"**/fixtures/**"
]
}
================================================
FILE: README.md
================================================
bundless
Next gen dev server and bundler
this project was a Vite alternative with many improvements like plugins, monorepo support, etc, most of them were added back to Vite 2, use Vite instead
# Features
- 10x faster than traditional bundlers
- Error panel with sourcemap support
- jsx, typescript out of the box
- import assets, import css
### What's the difference with traditional tools like Webpack?
- Faster dev server times and faster build speeds (thanks to [esbuild](https://esbuild.github.io))
- Bundless serves native ES modules to the browser, removing the overhead of parsing each module before serving
- Bundless uses a superset of [esbuild plugin system](https://esbuild.github.io/plugins/) to let users enrich its capabilities
### What's the difference with tools like vite?
Bundless is very similar to vite, both serve native es modules to the browser and build a bundled version for production.
Also both are based on a plugin system that can be shared between the dev server and the bundler.
Some differences are:
- Bundless uses the esbuild plugin system instead of rollup
- Bundless uses esbuild instead of rollup for the production bundle
- Bundless still lacks some features like css modules (depends on [esbuild](https://github.com/evanw/esbuild/issues/20)) and more framework support (coming soon)
================================================
FILE: TODOS.md
================================================
- fix stack trace parsing in client, use https://github.com/marvinhagemeister/errorstacks
- make node polyfills an optional plugins list, but include it by default on default config
- add support for multiple errors in error panel
- check that inline sourcemaps are used by esbuild transformer
- add crypto polyfill
- when there is an error and using HMR, do not refresh, instead try to run react refresh and see if it works
- ~~use data url for loading svgs~~
- ~~resolved paths that map from a real file to a fake file won't receive HMR updates because there is no way to resolve them during file change~~
- ~~make a config for assetExtensions, to let user import any file and return its path~~
- ~~put the onResolve function in the plugins executor, this way it does not depend on the presence of node-resolve plugin~~
- ~~replace node-resolve in the traversal with bare imports plugin~~
- ~~add a way to order plugins after or before the builtin plugins~~
- ~~do not rely on the node resolve package for anything, add an additional plugin and add node-resolve only when in yarn pnp~~
- ~~replace external but in meta with a dummy plugin that registers imports~~
- ~~do not run esbuild transform if loader is already js~~
- ~~run all user plugins first, make react refresh use the js loader as output~~
- only use sourcemaps on user packages, npm packages seem to not publish src directory
- ~~dynamic imports should not reorder exports, depend on esbuild~~
- add warning for multiple node modules paths for same package when this package is peer of something
- remove require warnings from paged (only use require when platform is node)
- ~~investigate if using new extensions in a plugins require you to add a resolver, maybe add a universal resolver that resolves all extensions (if they are present in the import path)~~
- think about core feature for bundless for promotion in twitter (esbuild plugins, benchmarks, ssr, meta framework, build speed, monorepo support, hmr fixes, multiple entrypoints,)
- how to make project sustainable? offer migration support for react-scripts and stuff like that?
- ~~makes bundless internal stuff paths start with .bundless, makes easier to analyze network requests~~
- ~~put everything inside .bundless, make this directory path configurable, this way tools like vitro can use .vitro~~
- use basePath to change the index.html page relative urls, this way there is no need to %PUBLIC_URL% need, / -> /base-path/
- more tests for hmr, using puppeteer
- test sourcemaps are correct, throwing errors and checking the browser error line
- test the html entries resolution (public, name.html, html paths in entries, ...)
- implement postcss processing to enable sass, tailwind, ...
================================================
FILE: bundless/CHANGELOG.md
================================================
# @bundless/cli
## 0.6.0
### Minor Changes
- Fixed problems with yarn berry and missing prebundled packages, better console messages
## 0.5.1
### Patch Changes
- Fix dead lock when not passing entries during prebundle
## 0.5.0
### Minor Changes
- Implemented immutable cache for all files, much faster refresh speed
## 0.4.0
### Minor Changes
- Cache dependencies, fix NODE_ENV variable always in production when prebundling
## 0.3.0
### Minor Changes
- Many improvements
## 0.2.6
### Patch Changes
- Updated esbuild
## 0.2.5
### Patch Changes
- Added support for importableAssetsExtensions
## 0.2.4
### Patch Changes
- 717a68e: Fix npm release, removed bin
## 0.2.3
### Patch Changes
- bd7ed34: Added enforce option to plugins
## 0.2.2
### Patch Changes
- 709ef96: Fix define assignments in client template
## 0.2.1
### Patch Changes
- ca42b40: Fix define runtime error in client code
## 0.2.0
### Minor Changes
- 9a0b4e5: Do not use esbuild when loader is js, inject defines in window
## 0.1.9
### Patch Changes
- 0c5c9b2: Store web_modules inside .bundless
## 0.1.8
### Patch Changes
- bbbd527: Bump
## 0.1.7
### Patch Changes
- 325516d: rename dotdot encondig to **..**
- f7684e8: Added basepath support
## 0.1.6
### Patch Changes
- 3541033: Added includeWorkspacePackages option
## 0.1.5
### Patch Changes
- 2e6022f: Small improvements
## 0.1.4
### Patch Changes
- 9c57b90: Better build logs
## 0.1.3
### Patch Changes
- 1b976b6: Less noise in logs, prebundle at start
## 0.1.2
### Patch Changes
- 410f40a: Better logs on nonResolved
## 0.1.1
### Patch Changes
- 7eaff10: Export babelParserOptions
## 0.1.0
### Minor Changes
- 81c8e26: First release
================================================
FILE: bundless/bin.js
================================================
#!/usr/bin/env node
require('./dist/cli')
================================================
FILE: bundless/package.json
================================================
{
"name": "@bundless/cli",
"version": "0.6.0",
"description": "",
"main": "dist/index.js",
"module": "esm/index.js",
"types": "dist/index.d.ts",
"repository": "https://github.com/remorses/esbuild-plugins.git",
"scripts": {
"build": "tsc && tsc -m esnext --outDir esm",
"watch:esm": "tsc -w -m esnext --outDir esm",
"watch:cjs": "tsc -w",
"watch": "run-p watch:esm watch:cjs",
"local": "yarn publish --force --registry http://localhost:4873 --access restricted --no-git-tag-version --patch --message 'Local registry publish'",
"cli": "node bin.js"
},
"files": [
"dist",
"src",
"esm",
"bin.js"
],
"bin": {
"bundless": "bin.js"
},
"keywords": [],
"author": "Tommaso De Rossi, morse ",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.12.10",
"@types/chokidar": "^2.1.3",
"@types/dotenv": "^8.2.0",
"@types/es-module-lexer": "^0.3.0",
"@types/find-up": "^4.0.0",
"@types/fs-extra": "^9.0.5",
"@types/koa": "^2.11.6",
"@types/koa-send": "^4.1.2",
"@types/node": "^14.14.13",
"@types/prompts": "^2.0.9",
"@types/qs": "^6.9.5",
"@types/ws": "^7.4.0",
"@types/yargs": "^15.0.11",
"npm-run-all": "^4.1.5",
"qs": "^6.9.4"
},
"dependencies": {
"@babel/parser": "^7.12.11",
"@babel/types": "^7.12.10",
"@esbuild-plugins/all": "^0.0.27",
"@koa/cors": "^3.1.0",
"chalk": "^4.1.0",
"chokidar": "^3.5.1",
"deepmerge": "^4.2.2",
"degit": "^2.8.0",
"dotenv": "^8.2.0",
"dotenv-expand": "^5.1.0",
"@esbuild-plugins/node-globals-polyfill": "^0.1.0",
"es-module-lexer": "^0.3.26",
"esbuild": "^0.11.3",
"escape-string-regexp": "^4.0.0",
"find-up": "^5.0.0",
"fromentries": "^1.3.2",
"fs-extra": "^9.0.1",
"get-port-please": "^2.1.0",
"hash-sum": "^2.0.0",
"koa": "^2.13.0",
"koa-etag": "^4.0.0",
"koa-send": "^5.0.1",
"launch-editor": "^2.2.1",
"lodash": "^4.17.20",
"lru-cache": "^6.0.0",
"magic-string": "^0.25.7",
"merge-source-map": "^1.1.0",
"micro-memoize": "^4.0.9",
"mime-types": "^2.1.29",
"ora": "^5.2.0",
"picomatch": "^2.2.2",
"posthtml": "^0.15.1",
"prompts": "^2.4.0",
"qs": "^6.9.4",
"simple-statistics": "^7.4.0",
"slash": "^3.0.0",
"source-map": "^0.7.3",
"source-map-support": "^0.5.19",
"strip-ansi": "^6.0.0",
"tmpfile": "^0.2.0",
"ws": "^7.4.1",
"yargs": "^16.2.0"
},
"peerDependencies": {}
}
================================================
FILE: bundless/src/build/index.ts
================================================
import deepmerge from 'deepmerge'
import * as esbuild from 'esbuild'
import fromEntries from 'fromentries'
import fs from 'fs-extra'
import path from 'path'
import { Plugin } from '../plugins-executor'
import posthtml, { Node } from 'posthtml'
import slash from 'slash'
import { Config, defaultConfig, getEntries, normalizeConfig } from '../config'
import { MAIN_FIELDS } from '../constants'
import { Logger } from '../logger'
import * as plugins from '../plugins'
import { PluginsExecutor } from '../plugins-executor'
import {
commonEsbuildOptions,
generateDefineObject,
metafileToBundleMap,
metafileToStats,
defaultResolvableExtensions,
} from '../prebundle/esbuild'
import { printStats } from '../prebundle/stats'
import { isUrl, runFunctionOnPaths, stripColon } from '../prebundle/support'
import { metaToTraversalResult } from '../prebundle/traverse'
import {
cleanUrl,
computeDuration,
osAgnosticPath,
partition,
removeLeadingSlash,
} from '../utils'
interface OwnArgs {
logger?: Logger
incremental?: boolean
}
// how to get entrypoints? to support multi entry i should let the user pass them, for the single entry i can just get public/index.html or index.html
// TODO add watch feature for build
export async function build({
logger = new Logger(),
incremental,
...config
}: Config & OwnArgs): Promise<{
bundleMap
traversalGraph
rebuild?: esbuild.BuildInvalidate
}> {
config = normalizeConfig(config)
const {
minify = false,
outDir = 'out',
jsTarget = 'es2018',
basePath = '/',
} = config.build || {}
const startTime = Date.now()
const { platform = 'browser', root = '' } = config
const isBrowser = platform === 'browser'
const userPlugins = config.plugins || []
await fs.remove(outDir)
await fs.ensureDir(outDir)
const publicDir = path.resolve(root, 'public')
const esbuildCwd = process.cwd()
if (fs.existsSync(publicDir)) {
await fs.copy(publicDir, outDir)
}
const mainFields = isBrowser ? MAIN_FIELDS : ['main', 'module']
const initialOptions: esbuild.BuildOptions = {
...commonEsbuildOptions(config),
incremental,
metafile: true,
logLevel: 'warning',
bundle: true,
platform,
target: jsTarget,
publicPath: basePath,
splitting: isBrowser,
// external: externalPackages,
minifyIdentifiers: Boolean(minify),
minifySyntax: Boolean(minify),
minifyWhitespace: Boolean(minify),
mainFields,
define: {
...generateDefineObject({ config, platform, isProd: true }),
},
// tsconfig: tsconfigTempFile,
format: isBrowser ? 'esm' : 'cjs',
write: true,
outdir: outDir,
minify: Boolean(minify),
}
const allPlugins: Plugin[] = [
...userPlugins,
plugins.HtmlResolverPlugin(),
plugins.HtmlIngestPlugin({
root,
name: 'html-ingest',
transformImportPath: cleanUrl,
}),
plugins.UrlResolverPlugin(),
plugins.NodeResolvePlugin({
name: 'node-resolve',
onNonResolved: (p) => {
// throw new Error(`Cannot resolve '${p}'`)
},
onResolved: (p) => {
if (platform !== 'node') {
return
}
// needed for linked workspaces
// const isOutside = path.relative(root, p).startsWith('..')
// TODO should i bundle linked dependencies in ssr build?
if (p.endsWith('.js') && p.includes('node_modules')) {
return {
path: p,
external: true,
}
}
},
mainFields,
extensions: [
...defaultResolvableExtensions,
...(Object.keys(config.loader || {}) || []),
],
}),
...(isBrowser
? [
plugins.NodeModulesPolyfillPlugin(),
plugins.NodeGlobalsPolyfillPlugin({
buffer: true,
process: true,
define: initialOptions.define,
}),
]
: []),
// html ingest should override other html plugins in build, this is because html is transformed to js
]
const pluginsExecutor = new PluginsExecutor({
plugins: allPlugins,
initialOptions,
isProfiling: config.printStats,
ctx: { config, isBuild: true, root },
})
const initialEntries = await getEntries(pluginsExecutor, config)
const entryPoints = await Promise.all(
initialEntries.map(async (x) => {
const resolved = await pluginsExecutor.resolve({
path: x,
resolveDir: root,
})
if (!resolved || !resolved.path) {
throw new Error(`Cannot resolve entry ${x} with plugins`)
}
return resolved.path
}),
)
logger.log(
`Building with esbuild ${entryPoints
.filter((f) => fs.existsSync(f))
.map((x) => osAgnosticPath(x, root))
.join(', ')}\n`,
)
let { rebuild, metafile } = await esbuild.build({
...initialOptions,
entryPoints,
plugins: pluginsExecutor.esbuildPlugins(),
})
let meta: esbuild.Metafile = metafile!
if (config.printStats && !logger.silent) {
console.info(pluginsExecutor.printProfilingResult())
}
logger.debug('finished esbuild build')
meta = runFunctionOnPaths(meta!, (p) => {
p = stripColon(p) // namespace:/path/to/file -> /path/to/file
return p
})
const bundleMap = metafileToBundleMap({
esbuildCwd,
meta,
root,
})
const traversalGraph = await metaToTraversalResult({
meta,
entryPoints,
root,
esbuildCwd,
})
// no outputs?
if (!Object.keys(bundleMap).length) {
return {
bundleMap,
traversalGraph,
rebuild: rebuild,
}
}
const cssToPreload: Record = fromEntries(
entryPoints.map((x) => osAgnosticPath(x, root)).map((k) => [k, []]),
)
// find all the css files, for every entry file traverse its imports and collect all css files, add the css outputs to cssToInject
for (let entry of entryPoints.map((x) => osAgnosticPath(x, root))) {
traverseGraphDown({
entryPoints: [entry],
traversalGraph,
onNode(imported) {
if (cleanUrl(imported).endsWith('.css')) {
const abs = path.resolve(root, imported)
let output = Object.keys(meta.outputs).find((x) => {
if (!x.endsWith('.css')) {
return
}
const info = meta.outputs[x]
const absInputs = new Set(
Object.keys(info.inputs).map((x) =>
path.resolve(esbuildCwd, x),
),
)
if (absInputs.has(abs)) {
return true
}
})
if (!output) {
throw new Error(`Cannot find output for '${imported}'`)
}
output = path.resolve(esbuildCwd, output)
cssToPreload[entry].push(output)
}
},
})
}
// TODO remove complete css injection after esbuild has css code splitting via js
const cssToInject = Object.keys(meta.outputs).filter((x) =>
x.endsWith('.css'),
)
// needed to run the onTransform on html entries
const htmlPluginsExecutor = new PluginsExecutor({
initialOptions: initialOptions,
plugins: [plugins.HtmlResolverPlugin(), ...userPlugins],
ctx: pluginsExecutor.ctx,
})
for (let entry of entryPoints) {
if (path.extname(entry) === '.html') {
const relativePath = osAgnosticPath(entry, root)
if (!bundleMap[relativePath]) {
throw new Error(
`Cannot find output for '${relativePath}' in ${JSON.stringify(
bundleMap,
null,
4,
)}`,
)
}
let outputJs = path.resolve(root, bundleMap[relativePath]!)
// let outputHtmlPath = path.resolve(
// root,
// path.dirname(bundleMap[relativePath]!),
// path.basename(entry),
// )
// await fs.copyFile(entry, outputHtmlPath)
const {
contents: html = '',
} = await htmlPluginsExecutor.resolveLoadTransform({ path: entry })
if (!html) {
throw new Error(`Cannot load html for ${entry}`)
}
const transformer = posthtml(
[
(tree) => {
// remove previous script tags
tree.walk((node) => {
if (
node &&
node.tag === 'script' &&
node.attrs &&
node.attrs['type'] === 'module' &&
node.attrs['src'] &&
!isUrl(node.attrs['src'])
) {
// TODO maybe leave script tags that are not resolved by plugin executor, maybe they are loaded from some cdn or who knows what, resolver should be able to resolve relative urls
node.tag = false as any
node.content = []
}
return node
})
// add new output files back to html
tree.match({ tag: 'body' }, (node) => {
const jsSrc = path.posix.join(
basePath,
slash(path.relative(outDir, outputJs)),
)
node.content = [
MyNode({
tag: 'script',
attrs: { type: 'module', src: jsSrc },
}),
...(node.content || []),
]
return node
})
// insert head if missing
if (!/ {
html.content = insertAfterStrings(
html.content,
MyNode({ tag: 'head', content: [] }),
)
return html
})
} else {
if (Array.isArray(tree)) {
tree = Object.assign(
tree,
insertAfterStrings(
tree,
MyNode({
tag: 'head',
content: [],
}),
),
)
}
}
}
tree.match({ tag: 'head' }, (node) => {
const cssPreloadHrefs =
cssToPreload[osAgnosticPath(entry, root)] || []
node.content = [
// TODO maybe include imported fonts as links?
...cssPreloadHrefs.map((href) => {
href = path.posix.join(
basePath,
slash(path.relative(outDir, href)),
)
return MyNode({
tag: 'link',
attrs: {
href,
rel: 'preload',
as: 'style',
},
})
}),
...cssToInject.map((href) => {
href = path.posix.join(
basePath,
slash(path.relative(outDir, href)),
)
return MyNode({
tag: 'link',
attrs: {
href,
rel: 'stylesheet',
},
})
}),
...(node.content || []),
]
return node
})
},
// !minify && beautify({ rules: { indent: 2 } }),
].filter(Boolean),
)
const result = await transformer.process(html).catch((e) => {
throw new Error(
`Cannot process html with posthtml: ${e}\n${html}`,
)
})
let htmlOutputDirname = path.normalize(
path.dirname(path.relative(root, entry)),
)
// remove `public` from entry path
if (htmlOutputDirname.startsWith('public')) {
htmlOutputDirname = htmlOutputDirname.replace(
/public(\/|\\)?/,
'',
)
}
const outputHtmlPath = path.resolve(
outDir,
htmlOutputDirname,
path.basename(entry),
)
await fs.ensureDir(path.dirname(outputHtmlPath))
await fs.writeFile(outputHtmlPath, result.html)
// emit html to dist directory, in dirname same as the output files corresponding to html entries
} else {
// if entry is not html, create an html file that imports the js output bundle
}
}
logger.log(`Saved files to ./${osAgnosticPath(outDir, process.cwd())}`)
if (!logger.silent) {
console.info(
printStats({
dependencyStats: metafileToStats({ meta, destLoc: outDir }),
destLoc: path.basename(outDir),
}),
)
}
logger.log(
`Built to ${
/^\w/.test(outDir) ? './' + outDir : outDir
} in ${computeDuration(startTime)}`,
)
return {
bundleMap,
// TODO rebuild should also trigger index.html rewrite, wrap rebuild function
rebuild,
traversalGraph,
}
}
function insertAfterStrings(items, node) {
const [strings, nonStrings] = partition(items, (x) => typeof x === 'string')
return [...strings, node, ...nonStrings]
}
function MyNode(x: Partial): Node {
return x as any
}
function traverseGraphDown(args: {
traversalGraph: Record
entryPoints: string[]
onNode
}) {
const { entryPoints, traversalGraph, onNode } = args
const toVisit: string[] = entryPoints
const visited = new Set()
while (toVisit.length) {
const entry = toVisit.shift()
if (!entry || visited.has(entry)) {
break
}
visited.add(entry)
const imports = traversalGraph[entry]
if (!imports) {
throw new Error(
`Node for '${entry}' not found in graph: ${JSON.stringify(
JSON.stringify(Object.keys(traversalGraph), null, 4),
)}`,
)
}
if (onNode) {
onNode(entry)
}
toVisit.push(...imports)
}
}
================================================
FILE: bundless/src/cli.ts
================================================
#!/usr/bin/env node
require('source-map-support').install()
if (process.argv.includes('--debug')) {
process.env.DEBUG_BUNDLESS = 'true'
}
import degit from 'degit'
import prompts from 'prompts'
import deepMerge from 'deepmerge'
import yargs, { CommandModule } from 'yargs'
import { build } from './build'
import { Config, loadConfig } from './config'
import { CONFIG_NAME, EXAMPLES_FOLDERS } from './constants'
import { serve } from './serve'
import { logger } from './logger'
import path from 'path'
const serveCommand: CommandModule = {
command: ['dev', 'serve', '*'],
builder: (argv) => {
argv.option('port', {
alias: 'p',
type: 'number',
description: 'The port for the dev server',
})
argv.option('force', {
alias: 'f',
type: 'boolean',
description:
'Force prebundling even if dependencies did not change',
})
return argv
},
handler: prettyPrintErrors(async (argv: any) => {
const loadedConfig = loadConfig(process.cwd(), argv.config)
const configFromArgv: Config = {
prebundle: { force: argv.force },
server: {
port: argv.port,
},
printStats: argv.stats,
}
let config: Config = deepMerge(loadedConfig, configFromArgv)
return await serve(config)
}),
}
const buildCommand: CommandModule = {
command: ['build'],
builder: (argv) => {
argv.option('outDir', {
alias: 'o',
type: 'string',
description: 'The output directory',
})
return argv
},
handler: prettyPrintErrors(async (argv: any) => {
let config = loadConfig(process.cwd(), argv.config)
const configFromArgv: Config = {
build: {
outDir: argv.outDir,
},
printStats: argv.stats,
}
config = deepMerge(config, configFromArgv)
return await build({
...config,
})
}),
}
const quickstartCommand: CommandModule = {
command: ['quickstart '],
builder: (argv) => {
argv.positional('outDir', { type: 'string' })
return argv
},
handler: prettyPrintErrors(async (argv: any) => {
const exampleDir = await prompts({
type: 'select',
name: 'value',
message: 'What example do you want to use?',
choices: EXAMPLES_FOLDERS.map(
(message: string): prompts.Choice => ({
title: message,
value: message,
}),
),
})
if (!exampleDir.value) {
logger.log(`Nothing done`)
return
}
logger.log(`Downloading ${exampleDir.value} example to ${argv.outDir}`)
const emitter = degit(
path.posix.join('remorses/bundless/examples', exampleDir.value),
{
verbose: true,
},
)
emitter.on('info', (info) => {
logger.debug(info.message)
})
await emitter.clone(argv.outDir)
logger.log(`Downloaded example to ./${path.normalize(argv.outDir)}`)
}),
}
yargs
.scriptName('bundless')
.locale('en')
.option('config', {
alias: 'c',
type: 'string',
default: CONFIG_NAME,
description: `The config path to use`,
})
.option('debug', {
type: 'boolean',
description: `Enables debug logging`,
})
.option('stats', {
type: 'boolean',
description: 'Show profiling stats',
})
.command(serveCommand)
.command(buildCommand)
.command(quickstartCommand)
.version()
.help('help', 'h').argv
function prettyPrintErrors(fn) {
return async (...args) => {
try {
return await fn(...args)
} catch (e) {
logger.error(e.message)
logger.error(e.stack)
}
}
}
================================================
FILE: bundless/src/client/template.ts
================================================
// This file runs in the browser.
// injected by serverPluginClient when served
declare const sourceMapSupport: any
declare const __HMR_PROTOCOL__: string
declare const __HMR_HOSTNAME__: string
declare const __HMR_PORT__: string
declare const __HMR_TIMEOUT__: number
declare const __HMR_ENABLE_OVERLAY__: boolean
declare const __DEFINES__: Record
const defines = __DEFINES__
Object.keys(defines).forEach((key) => {
const segs = key.split('.')
let target = window as any
for (let i = 0; i < segs.length; i++) {
const seg = segs[i]
if (i === segs.length - 1) {
target[seg] = defines[key]
} else {
target = target[seg] || (target[seg] = {})
}
}
})
import {
OverlayErrorPayload,
HMRPayload,
UpdatePayload,
OverlayInfoOpenPayload,
} from './types'
// use server configuration, then fallback to inference
const socketProtocol =
__HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
const socketURL = `${socketProtocol}://${socketHost}`
const isWindowDefined = typeof window !== 'undefined'
function log(...args) {
console.info('[ESM-HMR]', ...args)
}
function reload() {
if (!isWindowDefined) {
return
}
location.reload(true)
}
let SOCKET_MESSAGE_QUEUE: HMRPayload[] = []
let connected = false
function _sendSocketMessage(msg) {
socket.send(JSON.stringify(msg))
}
function sendSocketMessage(msg: HMRPayload) {
if (!connected) {
SOCKET_MESSAGE_QUEUE.push(msg)
} else {
_sendSocketMessage(msg)
}
}
const socket = new WebSocket(socketURL, 'esm-hmr')
const REGISTERED_MODULES: { [path: string]: HotModuleState } = {}
class HotModuleState {
data = {}
isLocked = false
isDeclined = false
isAccepted = false
acceptCallbacks: { deps: string[]; callback: Function }[] = []
disposeCallbacks: Function[] = []
path = ''
constructor(path) {
this.path = path
}
lock() {
this.isLocked = true
}
dispose(callback) {
this.disposeCallbacks.push(callback)
}
invalidate() {
reload()
}
decline() {
this.isDeclined = true
}
accept(_deps, callback: Function | true = true) {
if (this.isLocked) {
return
}
if (!this.isAccepted) {
sendSocketMessage({ path: this.path, type: 'hotAccept' })
this.isAccepted = true
}
if (!Array.isArray(_deps)) {
callback = _deps || callback
_deps = []
}
if (callback === true) {
callback = () => {}
}
const deps = _deps.map((dep) => {
return new URL(dep, `${window.location.origin}${this.path}`)
.pathname
})
this.acceptCallbacks.push({
deps,
callback,
})
}
}
export function createHotContext(fullUrl) {
const id = new URL(fullUrl).pathname
const existing = REGISTERED_MODULES[id]
if (existing) {
existing.lock()
runModuleDispose(id)
return existing
}
const state = new HotModuleState(id)
REGISTERED_MODULES[id] = state
return state
}
/** Called when a new module is loaded, to pass the updated module to the "active" module */
// uses the graph lastUsedTimestamp to make the new timestamp to fetch, pass this in the hmr message?
async function runModuleAccept({ path, namespace, updateID }: UpdatePayload) {
const state = REGISTERED_MODULES[path]
if (!state) {
log(`${path} has not been registered, reloading`)
log(Object.keys(REGISTERED_MODULES))
return false
}
if (state.isDeclined) {
log(`${path} has declined HMR, reloading`)
return false
}
const acceptCallbacks = state.acceptCallbacks
for (const { deps, callback: acceptCallback } of acceptCallbacks) {
const encodedNamespace = encodeURIComponent(namespace || 'file')
const [module, ...depModules] = await Promise.all([
import(
appendQuery(path, `namespace=${encodedNamespace}&t=${updateID}`)
),
...deps.map(
(d) => import(appendQuery(d, `t=${Date.now()}&namespace=file`)),
), // TODO deps should have the namespace and their update ids too, how?
])
acceptCallback({ module, deps: depModules })
}
return true
}
/** Called when a new module is loaded, to run cleanup on the old module (if needed) */
async function runModuleDispose(id) {
const state = REGISTERED_MODULES[id]
if (!state) {
return false
}
if (state.isDeclined) {
return false
}
const disposeCallbacks = state.disposeCallbacks
state.disposeCallbacks = []
state.data = {}
disposeCallbacks.map((callback) => callback())
return true
}
function getErrorMessageMappedSource(message) {
if (typeof sourceMapSupport !== 'undefined') {
return (
sourceMapSupport.getErrorSource({
message,
name: '',
stack: '',
}) || message
)
}
return message
}
function getErrorStackMappedSource(stack) {
if (typeof sourceMapSupport !== 'undefined') {
return (
sourceMapSupport
.getErrorSource({
stack,
message: '',
name: '',
})
?.trim?.() || stack
)
}
return stack
}
socket.addEventListener('message', ({ data: _data }) => {
if (!_data) {
return
}
const data: HMRPayload = JSON.parse(_data)
if (data.type === 'connected') {
connected = true
SOCKET_MESSAGE_QUEUE.forEach(_sendSocketMessage)
SOCKET_MESSAGE_QUEUE = []
setInterval(() => {
try {
socket.send(JSON.stringify({ type: 'ping' }))
} catch {}
}, __HMR_TIMEOUT__)
return
}
if (data.type === 'reload') {
log('message: reload')
reload()
return
}
if (data.type === 'overlay-error') {
log('message: error')
InfoOverlay.clear()
ErrorOverlay.show(data.err)
return
}
if (data.type === 'overlay-info-open') {
log('message: info open')
ErrorOverlay.clear()
InfoOverlay.show({ ...data.info, stack: '' })
return
}
if (data.type === 'overlay-info-close') {
log('message: info close')
InfoOverlay.clear()
return
}
if (data.type === 'update') {
if (ErrorOverlay.isOpen()) {
log(`error overlay is open: reloading`)
return reload()
}
log('message: update', data)
runModuleAccept(data)
.then((ok) => {
if (ok) {
ErrorOverlay.clear()
InfoOverlay.clear()
} else {
reload()
}
})
.catch((err) => {
console.error('[ESM-HMR] Hot Update Error', err)
// A failed import gives a TypeError, but invalid ESM imports/exports give a SyntaxError.
// Failed build results already get reported via a better WebSocket update.
// We only want to report invalid code like a bad import that doesn't exist.
if (err instanceof SyntaxError) {
ErrorOverlay.show({
message: `Hot Update Error for ${data.path}: ${err.message}`,
stack: err.stack || '',
})
}
})
return
}
log('message: unknown', data)
})
log('listening for file changes...')
/** Runtime error reporting: If a runtime error occurs, show it in an overlay. */
if (isWindowDefined) {
window.addEventListener('error', function (event) {
const err: OverlayErrorPayload['err'] = {
message: `${event.message}`,
stack: event.error ? event.error.stack : '',
}
ErrorOverlay.show(err)
})
}
const enableOverlay = __HMR_ENABLE_OVERLAY__
function appendQuery(url: string, query: string) {
if (query.startsWith('?')) {
query = query.slice(1)
}
if (url.includes('?')) {
return url + query
}
return `${url}?${query}`
}
const template = ({ mainColor, tip = '' }) => /*html*/ `
`
class CommonOverlay extends HTMLElement {
root?: ShadowRoot
static overlayId: string = 'overlay'
static isOpen() {
const elements = document.querySelectorAll(this.overlayId)
return elements.length > 0
}
static show(arg) {
if (!enableOverlay) return
this.clear()
// @ts-ignore
const instance = new this(arg)
document.body.appendChild(instance)
}
static clear() {
document
.querySelectorAll(this.overlayId)
.forEach((n) => (n as ErrorOverlay).close())
}
close() {
this.parentNode?.removeChild(this)
}
displayText(selector: string, text: string, linkFiles = false) {
const el = this.root!.querySelector(selector)!
if (!linkFiles) {
el.textContent = text
} else {
// TODO also match normal file paths
const matches = getAllMatches(text, /(https?:\/\/.*)/g)
for (let { frag, matched } of matches) {
el.appendChild(document.createTextNode(frag))
const link = document.createElement('a')
link.textContent = matched
link.className = 'file-link'
const isUrl = /https?:\/\//.test(matched)
let path = isUrl ? new URL(matched).pathname : matched
const fileLocationRegex = /(:\d+:\d+)$/
if (!fileLocationRegex.test(path)) {
const lineNumAndCol =
fileLocationRegex.exec(matched)?.[1] || ''
path += lineNumAndCol
}
link.onclick = () => {
console.info(`Opening ${path} in editor`)
fetch('/__open-in-editor?file=' + encodeURIComponent(path))
}
el.appendChild(link)
}
}
}
}
function getAllMatches(text: string, regex: RegExp) {
let curIndex = 0
let match
const matches: { frag: string; matched: string }[] = []
while ((match = regex.exec(text))) {
let { 0: matched, index } = match
matched = matched.trim()
if (index != null) {
const frag = text.slice(curIndex, index)
matches.push({ frag, matched })
curIndex += frag.length + matched.length
}
}
return matches
}
export class ErrorOverlay extends CommonOverlay {
root: ShadowRoot
static overlayId = 'bundless-error-overlay'
constructor(err: OverlayErrorPayload['err']) {
console.log({ err })
super()
this.root = this.attachShadow({ mode: 'open' })
this.root.innerHTML = template({
mainColor: '--red',
tip: `Click outside or fix the code to dismiss.
`,
})
if (err.plugin) {
this.displayText('.plugin', `[plugin:${err.plugin}] `)
}
const message = getErrorMessageMappedSource(err.message)
this.displayText('.message-body', message.trim())
const stack = getErrorStackMappedSource(err.stack)
this.displayText('.stack', stack.trim(), true)
this.root.querySelector('.window')!.addEventListener('click', (e) => {
e.stopPropagation()
})
this.addEventListener('click', () => {
this.close()
})
}
}
customElements.define(ErrorOverlay.overlayId, ErrorOverlay)
export class InfoOverlay extends CommonOverlay {
root: ShadowRoot
static overlayId = 'bundless-info-overlay'
constructor(info: OverlayInfoOpenPayload['info']) {
super()
this.root = this.attachShadow({ mode: 'open' })
this.root.innerHTML = template({ mainColor: '--cyan' })
this.displayText('.message-body', info.message.trim())
this.root.querySelector('.window')!.addEventListener('click', (e) => {
e.stopPropagation()
})
// this.addEventListener('click', () => {
// this.close()
// })
}
}
customElements.define(InfoOverlay.overlayId, InfoOverlay)
// InfoOverlay.show({ message: 'Prebundling modules' })
================================================
FILE: bundless/src/client/types.ts
================================================
export type HMRPayload =
| ConnectedPayload
| UpdatePayload
| FullReloadPayload
| OverlayErrorPayload
| OverlayInfoOpenPayload
| OverlayInfoClosePayload
| HotAcceptPayload
| ConnectPayload
interface ConnectedPayload {
type: 'connected'
}
export interface UpdatePayload {
type: 'update'
path: string
updateID: string
namespace: string
// changeSrcPath: string
// timestamp: number
}
interface FullReloadPayload {
type: 'reload'
}
interface HotAcceptPayload {
type: 'hotAccept'
path: string
}
interface ConnectPayload {
type: 'connected'
}
export interface OverlayErrorPayload {
type: 'overlay-error'
err: {
// [name: string]: any
message: string
stack: string
id?: string
frame?: string
plugin?: string
pluginCode?: string
}
}
export interface OverlayInfoOpenPayload {
type: 'overlay-info-open'
info: {
[name: string]: any
message: string
showSpinner?: boolean
}
}
export interface OverlayInfoClosePayload {
type: 'overlay-info-close'
}
================================================
FILE: bundless/src/config.ts
================================================
import { CONFIG_NAME, DEFAULT_PORT } from './constants'
import findUp from 'find-up'
import fs from 'fs'
import { Plugin, PluginsExecutor } from './plugins-executor'
import path from 'path'
import * as esbuild from 'esbuild'
import deepmerge from 'deepmerge'
export async function getEntries(
pluginsExecutor: PluginsExecutor,
config: Config,
) {
const root = pluginsExecutor.ctx.root
if (config.entries) {
// for (let entry of config.entries) {
// if (config.platform === 'browser' && !entry.endsWith('.html')) {
// throw new Error(
// `When targeting browser config.entries can only contain html files: ${entry}`,
// )
// }
// }
return (
await Promise.all(
config.entries.map((x) =>
pluginsExecutor
.resolve({
path: x,
resolveDir: config.root,
skipOnResolved: true,
})
.then((x) => x?.path || ''),
),
)
)
.filter(Boolean)
.map((x) => path.resolve(root, x))
}
// public folder logic is already in the html resolver plugin
const index1 = await pluginsExecutor.resolve({
path: 'index.html',
skipOnResolved: true,
resolveDir: config.root,
})
if (index1?.path) {
return [path.resolve(root, index1.path)]
}
throw new Error(
`Cannot find entries, neither config.entries, index.html or public/index.html files are present\n${JSON.stringify(
config,
null,
4,
)}`,
)
}
export type Platform = 'node' | 'browser'
export function normalizeConfig(config: Config) {
config = deepmerge(defaultConfig, config)
config.plugins = (config.plugins || [])
.filter(Boolean)
.map((x) => ({ ...x, enforce: x.enforce || 'pre' }))
return config
}
// TODO add config.mainFields
// TODO add config.build.chunkNames, assetNames, entryNames
// TODO add config.inject
// TODO add config.watch
// TODO add config.resolveExtensions
// TODO add config.jsxFactory, jsxFragment
export interface Config {
server?: ServerConfig
define?: Record
prebundle?: PrebundlingConfig
build?: BuildConfig
printStats?: boolean
platform?: Platform
root?: string
// env?: Record
entries?: string[]
plugins?: Plugin[]
// TODO rename to loader to stay closer to esbuild
loader?: Record // TODO support more than file
jsx?:
| 'vue'
| 'preact'
| 'react'
| {
factory?: string
fragment?: string
}
}
export interface PrebundlingConfig {
force?: boolean
includeWorkspacePackages?: string[] | boolean // TODO if bundless is called on root this won't work (example is vitro), every path won't ever be outside root
}
export interface ServerConfig {
openBrowser?: boolean
cors?: boolean
port?: number | string
hmr?: HmrConfig | boolean
}
export const defaultConfig: Config = {
// entries: ['index.html'], // entry files
server: {
port: 3000,
hmr: true,
openBrowser: false, // opens browser on server start
},
prebundle: {
includeWorkspacePackages: false, // linked packages to prebundle
force: false, // forces prebundling dependencies on server start
},
build: {
basePath: '/',
jsTarget: 'es2018', // target es version
minify: true, // run esbuild minification
outDir: './out', // output directory
},
platform: 'browser', // target platform, browser or node
loader: {}, // extension that return their path when imported
jsx: 'react', // jsx preset
plugins: [],
define: {},
}
export function loadConfig(from: string, name = CONFIG_NAME): Config {
const configPath = findUp.sync(name, { cwd: from })
let config: Config = {}
if (configPath) {
config = require(configPath)
}
if (!config.root) {
config = { ...config, root: process.cwd() }
}
return config
}
export interface HmrConfig {
protocol?: string
hostname?: string
port?: number
path?: string
/**
* If you are using hmr ws proxy, it maybe timeout with your proxy program.
* You can set this option to let client send ping socket to keep connection alive.
* The option use `millisecond` as unit.
* @default 30000ms
*/
timeout?: number
}
export interface BuildConfig {
basePath?: string
outDir?: string
minify?: boolean
jsTarget?: string
}
================================================
FILE: bundless/src/constants.ts
================================================
import { logger } from './logger'
import * as esbuild from 'esbuild'
export let isRunningWithYarnPnp: boolean = false
export let pnpapi: any
try {
pnpapi = require('pnpapi')
isRunningWithYarnPnp = Boolean(pnpapi)
logger.debug('Using Yarn PnP')
} catch {}
export const hmrClientNamespace = 'hmr-client'
export const DEFAULT_PORT = 3000
export const CLIENT_PUBLIC_PATH = `/_hmr_client.js?namespace=${hmrClientNamespace}`
export const COMMONJS_ANALYSIS_PATH = '.bundless/commonjs.json'
export const WEB_MODULES_PATH = '.bundless/node_modules'
export const BUNDLE_MAP_PATH = '.bundless/bundleMap.json'
export const HMR_SERVER_NAME = 'esm-hmr'
export const CONFIG_NAME = 'bundless.config.js'
export const EXAMPLES_FOLDERS = [
'react-typescript',
'react-javascript',
'vanilla-javascript',
'svelte',
]
export const MAIN_FIELDS = ['browser:module', 'browser', 'module', 'main']
export const showGraph = process.env.SHOW_HMR_GRAPH
export const JS_EXTENSIONS = ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.cjs']
export const defaultLoader: Record = {
'.jpg': 'file',
'.jpeg': 'file',
'.png': 'file',
'.svg': 'dataurl',
'.gif': 'file',
'.ico': 'file',
'.webp': 'file',
'.jp2': 'file',
'.avif': 'file',
'.woff': 'file',
'.woff2': 'file',
'.ttf': 'file',
}
export const defaultImportableAssets = Object.keys(defaultLoader)
export const hmrPreamble = `import * as __HMR__ from '${CLIENT_PUBLIC_PATH}'; import.meta.hot = __HMR__.createHotContext(import.meta.url); `
================================================
FILE: bundless/src/hmr-graph.ts
================================================
import path from 'path'
import WebSocket from 'ws'
import net from 'net'
import crypto from 'crypto'
import chalk from 'chalk'
import { osAgnosticPath } from './utils'
import { fileToImportPath, importPathToFile } from './utils'
import { HMRPayload } from './client/types'
import { logger } from './logger'
import { HMR_SERVER_NAME } from './constants'
import slash from 'slash'
import { PluginsExecutor } from './plugins-executor'
// examples are ./main.js and ../folder/main.js
type OsAgnosticPath = string
// examples are /path/file.js or /__..__/file.js
type ImportPath = string
export interface HmrNode {
hash: string
importers(): Set // returns osAgnosticPaths
importees: Set
dirtyImportersCount: number // modules that have imported this and have been updated
lastUsedTimestamp: number
isHmrEnabled?: boolean
hasHmrAccept?: boolean
computedModules?: Set
}
export class HmrGraph {
// keys are always os agnostic paths and not public paths
nodes: { [osAgnosticPath: string]: HmrNode } = {}
root
wss: WebSocket.Server
server: net.Server
realToFake: Record>
constructor({ root, server }: { root: string; server: net.Server }) {
this.realToFake = {}
this.nodes = {}
this.root = root
this.server = server
const wss = new WebSocket.Server({ noServer: true })
this.wss = wss
server.once('close', () => {
wss.close(() => logger.debug('closing wss'))
wss.clients.forEach((client) => {
client.close()
})
})
server.on('upgrade', (req, socket, head) => {
if (req.headers['sec-websocket-protocol'] === HMR_SERVER_NAME) {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
})
}
})
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'connected' }))
socket.on('message', (data) => {
const message: HMRPayload = JSON.parse(data.toString())
if (message.type === 'hotAccept') {
this.ensureEntry(importPathToFile(root, message.path), {
hasHmrAccept: true,
isHmrEnabled: true,
})
}
})
})
wss.on('error', (e: Error & { code: string }) => {
if (e.code !== 'EADDRINUSE') {
console.error(chalk.red(`WebSocket server error:`))
console.error(e)
}
})
}
sendHmrMessage(payload: HMRPayload) {
if (!this.wss) {
throw new Error(`HMR Websocket server has not started yet`)
}
const stringified = JSON.stringify(payload, null, 4)
logger.debug(`hmr: ${stringified}`)
if (!this.wss.clients.size) {
logger.debug(`No clients listening for HMR message`)
}
let clientIndex = 1
for (let client of this.wss.clients.values()) {
if (client.readyState === WebSocket.OPEN) {
client.send(stringified)
} else {
logger.log(
chalk.red(
`Cannot send HMR message, hmr client ${clientIndex} is not open`,
),
)
}
clientIndex += 1
}
}
ensureEntry(path: string, newNode?: Partial): HmrNode {
path = osAgnosticPath(path, this.root)
if (this.nodes[path]) {
Object.assign(this.nodes[path], newNode || {})
return this.nodes[path]
}
this.nodes[path] = {
hash:
process.env.BUNDLESS_CONSISTENT_HMR_GRAPH_HASH != null
? process.env.BUNDLESS_CONSISTENT_HMR_GRAPH_HASH
: crypto.randomBytes(4).toString('hex'),
dirtyImportersCount: 0,
lastUsedTimestamp: 0,
hasHmrAccept: false,
isHmrEnabled: false,
importees: new Set(),
...newNode,
importers: () => {
const importPath = fileToImportPath(this.root, path)
return new Set(
Object.entries(this.nodes)
.filter(([_, v]) => {
return v.importees?.has(importPath)
})
.map(([k, _]) => k),
)
},
}
return this.nodes[path]
}
toString() {
const content = Object.keys(this.nodes)
.map((k) => {
const node = this.nodes[k]
let key = slash(path.relative(process.cwd(), k))
if (node.hasHmrAccept) {
key = chalk.redBright(chalk.underline(key))
} else if (node.isHmrEnabled) {
key = chalk.yellow(chalk.underline(key))
}
key += ' ' + chalk.cyan(node.dirtyImportersCount)
return ` ${key} -> ${JSON.stringify(
[...node.importees],
null,
4,
)
.split('\n')
.map((x) => ' ' + x)
.join('\n')
.trim()}`
})
.join('\n')
const legend =
`\nLegend:\n` +
// `${'[ ]'} has no HMR\n` +
`${chalk.redBright('[ ]')} accepts HMR\n` +
`${chalk.yellow('[ ]')} HMR enabled\n\n`
return legend + `ImportGraph {\n${content}\n}\n`
}
// TODO maybe rewrite should happen before to prune the graph from removed imports? in case old imports remain in the graph what could happen? the hmr algo only depend on the importers, this means that the worst thing could be that a non importer could be updated, but this is impossible because the only changed imports can only be the ones in the updated file, this means that only the current file imports could be invalid, which means that changed files importers will always be valid
// TODO to make this work for vue and vite, i need to support virtual files?, vite files will be rewritten as js files with imports of virtual css files, the current implementation will see the change in the vite file, but it cannot know about changed virtual files, maybe i can put a property in the result of onTransform or onLoad to say `computedFiles: [virtualFile]`, save this info in graph (taken during rewrite) and in onChange i can send an update to these dependent modules too
// TODO batch changes? this way if user dopy pastes a directory i don't have to traverse the graph for every file
async onFileChange({ filePath }: { filePath: string }) {
const graph = this
const root = this.root
const resolvedPaths = this.realToFake[filePath]
? [...this.realToFake[filePath]]
: [filePath]
const initialRelativePaths = resolvedPaths.map((resolvedPath) =>
osAgnosticPath(resolvedPath, root),
)
const messages: HMRPayload[] = []
const nodesToBeFetched: Set = new Set([])
// update all importers query fetch to not use browser cached module
await this.traverseUpwards({
entries: initialRelativePaths,
onTraverse: async (
relativePath: string,
node: HmrNode,
importers: Set,
) => {
// can be a non js file, like index.html
if (!node) {
return true
}
nodesToBeFetched.add(relativePath)
if (node.computedModules) {
for (let computed of node.computedModules) {
const node = graph.nodes[computed]
node.lastUsedTimestamp++
}
}
for (let importer of importers) {
nodesToBeFetched.add(importer)
}
return true
},
})
for (const relativePath of nodesToBeFetched) {
const node = graph.ensureEntry(relativePath)
node.lastUsedTimestamp++
}
await this.traverseUpwards({
entries: initialRelativePaths,
onTraverse: async (
relativePath: string,
node: HmrNode,
importers: Set,
) => {
const importPath = fileToImportPath(root, relativePath)
// can be a non js file, like index.html
if (!node) {
logger.log(
`node for '${relativePath}' not found in graph, reloading`,
)
this.sendHmrMessage({ type: 'reload' })
return
}
// trigger an update if the module is able to handle it
if (node.isHmrEnabled) {
messages.push({
type: 'update',
namespace: 'file',
path: importPath,
updateID: node.hash + node.lastUsedTimestamp,
})
// computed nodes are virtual nodes whose code depends on another node
if (node.computedModules) {
for (let computed of node.computedModules) {
const node = graph.nodes[computed]
node.dirtyImportersCount++
messages.push({
type: 'update',
namespace: 'file', // TODO do not hard code namespace for computed nodes
path: fileToImportPath(root, computed),
updateID: node.hash + node.lastUsedTimestamp,
})
}
}
}
// reached a boundary, stop hmr propagation
if (node.hasHmrAccept) {
return
}
// reached another boundary, reload
if (!importers.size) {
logger.log(
`reached top boundary '${relativePath}', reloading`,
)
this.sendHmrMessage({ type: 'reload' })
return
}
for (let importer of importers) {
graph.ensureEntry(importer)
// mark module as dirty, importers will refetch this module to see updates
node.dirtyImportersCount++ // TODO this means that the current node t must be changed, not the importer one, but given that now i include the t on every one maybe no nee d for this?
}
return true
},
})
messages.forEach((m) => this.sendHmrMessage(m))
}
async traverseUpwards({ onTraverse, entries }) {
const graph = this
const toVisit: string[] = [...entries]
const visited: string[] = []
while (toVisit.length) {
const relativePath = toVisit.shift()
if (!relativePath || visited.includes(relativePath)) {
continue
}
visited.push(relativePath)
// TODO if plugin resolver like css changes filename, it won't be found in graph
const node = graph.nodes[relativePath]
if (!node) {
return
}
const importers = node.importers()
const res = await onTraverse(relativePath, node, importers)
if (res) {
toVisit.push(...importers)
}
}
}
}
================================================
FILE: bundless/src/index.ts
================================================
export { serve } from './serve'
export { build } from './build'
export { Config, loadConfig } from './config'
export { Plugin, PluginsExecutor } from './plugins-executor'
export { logger, Logger } from './logger'
export { HmrGraph, HmrNode } from './hmr-graph'
export { MAIN_FIELDS } from './constants'
================================================
FILE: bundless/src/logger.ts
================================================
import chalk from 'chalk'
import ora, { Ora } from 'ora'
const defaultPrefix = '[bundless] '
const DEBUG = process.env.DEBUG_BUNDLESS
export class Logger {
prefix: string = ''
silent: boolean
constructor({ prefix = defaultPrefix, silent = false } = {}) {
this.prefix = prefix
this.silent = silent
}
private print(x) {
if (this.silent) {
return
}
if (this.spinner) {
this.spinner.info(x)
} else {
process.stderr.write(chalk.dim(this.prefix) + x + '\n')
}
}
log(...x) {
this.print(x.join(' '))
}
warn(...x) {
this.print(chalk.yellow(x.join(' ')))
}
error(...x) {
this.print(chalk.red(x.join(' ')))
}
private spinner?: Ora
spinStart(text: string) {
if (this.silent) {
return
}
this.spinner = ora(text + '\n\n').start()
}
spinSucceed(text: string) {
if (this.spinner) {
this.spinner.succeed(text)
}
this.spinner = undefined
}
spinFail(text: string) {
if (this.spinner) {
this.spinner.fail(chalk.redBright(text))
}
this.spinner = undefined
}
debug = DEBUG
? (...x) => {
if (this.spinner) {
this.spinner.info(x.join(' ') + '\n')
} else {
process.stderr.write(
chalk.dim(this.prefix + x.join(' ') + '\n'),
)
}
}
: () => {}
}
export const logger = new Logger()
================================================
FILE: bundless/src/middleware/history-fallback.ts
================================================
import { Middleware } from 'koa'
import path from 'path'
import slash from 'slash'
import { logger } from '../logger'
import { PluginsExecutor } from '../plugins-executor'
import { cleanUrl, importPathToFile } from '../utils'
export function historyFallbackMiddleware({
root,
pluginsExecutor,
}: {
root: string
pluginsExecutor: PluginsExecutor
}): Middleware {
return async (ctx, next) => {
if (ctx.status !== 404) {
return next()
}
if (ctx.method !== 'GET') {
logger.debug(`not redirecting ${ctx.url} (not GET)`)
return next()
}
const accept = ctx.headers && ctx.headers.accept
if (typeof accept !== 'string') {
logger.debug(`not redirecting ${ctx.url} (no headers.accept)`)
return next()
}
if (accept.includes('application/json')) {
logger.debug(`not redirecting ${ctx.url} (json)`)
return next()
}
if (!accept.includes('text/html')) {
logger.debug(`not redirecting ${ctx.url} (not accepting html)`)
return next()
}
// use the executor to resolve virtual html files
// TODO decide if we want to pass to plugins the path with appended index.html or the normal path and let the plugins decide if they watn to serve html, the second way is harder because html should be served as last thing (fallback) but user plugins run first
let filePath = !cleanUrl(ctx.path).endsWith('.html')
? path.posix.join(ctx.path, 'index.html')
: ctx.path
const {
contents: resolvedHtml,
path: resolveHtmlPath,
} = await pluginsExecutor.resolveLoadTransform({
path: importPathToFile(root, filePath),
skipOnResolved: true,
expectedExtensions: ['.html'],
})
if (resolvedHtml) {
send(
ctx,
resolvedHtml,
'/' + slash(path.relative(root, resolveHtmlPath || '')),
)
return next()
}
logger.debug(`fallback ${ctx.url} to html`)
// html resolver already search in public
const {
contents: resolvedTopHtml,
} = await pluginsExecutor.resolveLoadTransform({
path: path.resolve(root, 'index.html'),
skipOnResolved: true,
expectedExtensions: ['.html'],
})
if (resolvedTopHtml) {
send(ctx, resolvedTopHtml, '/index.html')
return next()
}
return next()
// return next()
}
}
function send(ctx, resolvedHtml, as = '') {
logger.debug(`Resolved html for ${ctx.path} as ${as}`)
ctx.body = resolvedHtml
ctx.status = 200
ctx.type = 'html'
// return next()
}
================================================
FILE: bundless/src/middleware/index.ts
================================================
export { sourcemapMiddleware } from './sourcemap'
export { historyFallbackMiddleware } from './history-fallback'
export { staticServeMiddleware } from './static-serve'
export { openInEditorMiddleware } from './open-in-editor'
export { pluginsMiddleware } from './plugins'
================================================
FILE: bundless/src/middleware/open-in-editor.ts
================================================
import { logger } from '..'
import fs from 'fs'
import launchEditor from 'launch-editor'
import { importPathToFile } from '../utils'
import { Middleware } from 'koa'
const fileLocationRegex = /(:\d+:\d+)$/
export function openInEditorMiddleware({ root }): Middleware {
return function(ctx, next) {
if (ctx.path !== '/__open-in-editor') {
return next()
}
const { file = '' } = ctx.query || {}
if (!file) {
ctx.res.statusCode = 500
ctx.body = `launch-editor-middleware: required query param "file" is missing.`
return
}
let realPath = fs.existsSync(file.replace(fileLocationRegex, '')) ? file : importPathToFile(root, file)
logger.log(`Opening editor at ${realPath}`)
launchEditor(realPath)
ctx.res.statusCode = 200
ctx.body = `Opened ${realPath}`
}
}
================================================
FILE: bundless/src/middleware/plugins.ts
================================================
import { FSWatcher } from 'chokidar'
import { Middleware } from 'koa'
import { WEB_MODULES_PATH } from '../constants'
import { logger } from '../logger'
import { PluginsExecutor } from '../plugins-executor'
import { importPathToFile, dotdotEncoding, genSourceMapString } from '../utils'
export function pluginsMiddleware({
root,
watcher,
pluginsExecutor,
}: {
root: string
watcher: FSWatcher
pluginsExecutor: PluginsExecutor
}): Middleware {
return async function pluginsMiddleware(ctx, next) {
if (
ctx.query.namespace == null ||
ctx.req.headers['accept'] !== '*/*'
) {
return next()
}
if (ctx.path.startsWith('.')) {
throw new Error(
`All import paths should have been rewritten to absolute paths (start with /)\n` +
` make sure import paths for '${ctx.path}' are statically analyzable`,
)
}
const isVirtual = ctx.query.namespace && ctx.query.namespace !== 'file'
// do not resolve virtual files like node builtins to an absolute path
const resolvedPath = isVirtual
? ctx.path.slice(1) // remove leading /
: importPathToFile(root, ctx.path)
// watch files outside root
if (
ctx.path.startsWith('/' + dotdotEncoding) &&
!resolvedPath.includes('node_modules')
) {
watcher.add(resolvedPath)
}
const namespace = ctx.query.namespace || 'file'
const loaded = await pluginsExecutor.load({
path: resolvedPath,
pluginData: undefined,
namespace,
})
if (loaded?.pluginData) {
logger.warn(
`esbuild pluginData is not supported by bundless, used by plugin ${loaded.pluginName}`,
)
}
if (loaded == null || loaded.contents == null) {
return next()
}
const transformed = await pluginsExecutor.transform({
path: resolvedPath,
loader: loaded.loader || 'default',
namespace,
contents: String(loaded.contents),
})
if (transformed == null) {
return next()
}
const sourcemap = transformed.map
? genSourceMapString(transformed.map)
: ''
ctx.body = transformed.contents + sourcemap
ctx.status = 200
ctx.type = 'js'
const isDep = ctx.path.includes(WEB_MODULES_PATH)
const isCacheableModule = ctx.query.t != null
ctx.set(
'Cache-Control',
isDep || isCacheableModule
? 'max-age=31536000,immutable'
: 'no-cache',
)
return next()
}
}
================================================
FILE: bundless/src/middleware/sourcemap.ts
================================================
import chalk from 'chalk'
import { Middleware } from 'koa'
import path from 'path'
import { RawSourceMap } from 'source-map'
import { logger } from '../logger'
import { importPathToFile, readFile } from '../utils'
// changes sourcemaps to point to right files
export const sourcemapMiddleware = ({ root }): Middleware => {
return async function sourcemap(ctx, next) {
if (!ctx.path.endsWith('.map')) {
return next()
}
logger.debug(`Handling sourcemap request for '${ctx.path}'`)
const filename = importPathToFile(root, ctx.path)
const content = await readFile(filename)
const map: RawSourceMap = JSON.parse(content)
if (!map.sources) {
logger.warn(`No sources found for sourcemap '${ctx.path}'`)
return next()
}
if (map.sourcesContent && map.sources.every(path.isAbsolute)) {
return next()
}
const sourcesContent = map.sourcesContent || []
const sourceRoot = path.resolve(
path.dirname(filename),
map.sourceRoot || '',
)
map.sources = await Promise.all(
map.sources.map(async (source, i) => {
const originalPath = path.resolve(sourceRoot, source)
if (!sourcesContent[i]) {
try {
sourcesContent[i] = await readFile(originalPath)
} catch (err) {
if (err.code === 'ENOENT') {
console.error(
chalk.red(
`Sourcemap "${filename}" points to non-existent source: "${originalPath}"`,
),
)
return source
}
throw err
}
}
return originalPath
}),
)
map.sourcesContent = sourcesContent
const contents = JSON.stringify(map)
ctx.body = contents
ctx.status = 200
ctx.type = 'application/json'
}
}
================================================
FILE: bundless/src/middleware/static-serve.ts
================================================
import { Middleware } from 'koa'
import send, { SendOptions } from 'koa-send'
import { WEB_MODULES_PATH } from '../constants'
import { logger } from '../logger'
// like koa static but executes other middlewares after serving, needed to transform html afterwards
export function staticServeMiddleware(opts: SendOptions): Middleware {
opts.index = opts.index || 'index.html'
opts.hidden = opts.hidden || true
const cacheOptions: send.SendOptions = {
maxAge: 1000 * 60 * 60,
immutable: true,
}
return async function serve(ctx, next) {
if (ctx.method !== 'HEAD' && ctx.method !== 'GET') {
return next()
}
if (ctx.body) {
return next()
}
const isDep = ctx.path.includes(WEB_MODULES_PATH)
try {
logger.debug('Statically serving ' + ctx.path)
await send(ctx, ctx.path, { ...opts, ...(isDep && cacheOptions) })
} catch (err) {
if (err.status !== 404 && err.code !== 'ENOENT') {
throw new Error(`Cannot static serve ${ctx.path}: ${err}`)
}
}
await next()
}
}
================================================
FILE: bundless/src/plugins/assets.ts
================================================
import { NodeResolvePlugin } from '@esbuild-plugins/all'
import * as esbuild from 'esbuild'
import escapeStringRegexp from 'escape-string-regexp'
import fs from 'fs-extra'
import mime from 'mime-types'
import path from 'path'
import { defaultLoader } from '../constants'
import { PluginHooks } from '../plugins-executor'
import { fileToImportPath } from '../utils'
import { transform } from './esbuild'
export function AssetsPlugin({
loader: _loader,
}: {
loader?: Record
}) {
let loader = _loader || {}
loader = {
...defaultLoader,
...loader,
}
const extensions = Object.keys(loader)
const extensionsSet = new Set(extensions)
return {
name: 'assets',
setup: (hooks: PluginHooks) => {
const { onLoad, onResolve, ctx: { root, config } } = hooks
const filter = new RegExp(
'(' +
extensions
.filter((x) => x !== '.css') // css is handled in css plugin
.map(escapeStringRegexp)
.join('|') +
')$',
)
// what if an image is in another module and this resolver bypasses the node resolve plugin that runs the prebundle? maybe i need to throw? no because assets do not need to be optimized, i just need to make sure that node resolve is called before all other resolvers
NodeResolvePlugin({
name: 'assets-node-resolve',
isExtensionRequiredInImportPath: true,
extensions,
}).setup({
...hooks,
onLoad() {},
})
onLoad({ filter }, async (args) => {
const extension = path.extname(args.path)
if (!extensionsSet.has(extension)) {
return
}
const publicPath = fileToImportPath(root, args.path)
const loadedType = loader[extension]
if (loadedType === 'file') {
return {
contents: `export default ${JSON.stringify(
publicPath,
)}`,
}
}
let data = await await fs.readFile(args.path)
if (loadedType === 'js') {
return { contents: data.toString(), loader: 'js' }
}
if (
loadedType === 'jsx' ||
loadedType === 'ts' ||
loadedType === 'tsx'
) {
const res = await transform({
filePath: args.path,
src: data.toString(),
loader: loadedType,
config,
})
return {
contents: res.contents || '',
loader: 'js',
}
}
if (loadedType === 'base64') {
return {
contents: `export default "${data.toString('base64')}`,
loader: 'js',
}
}
if (loadedType === 'dataurl') {
const mimeType = mime.lookup(args.path)
return {
contents: `export default "data:${mimeType};base64,${data.toString(
'base64',
)}"`,
loader: 'js',
}
}
if (loadedType === 'text') {
return {
contents: `export default ${JSON.stringify(
data.toString(),
)}`,
loader: 'js',
}
}
if (loadedType === 'json') {
const transformed = await esbuild.transform(
data.toString(),
{
format: 'esm',
loader: 'json',
sourcefile: args.path,
},
)
return {
contents: transformed.code,
loader: 'js',
}
}
if (loadedType === 'binary') {
return {
contents: data.toString(), // how can i serve binary data to browser?
loader: 'js',
}
}
return null
})
},
}
}
================================================
FILE: bundless/src/plugins/buffer.ts
================================================
import * as esbuild from 'esbuild'
import { Plugin } from '../plugins-executor'
import { importPathToFile, readFile } from '../utils'
const BUFFER_PATH = '_bundless-node-buffer-polyfill_.js'
export function NodeBufferGlobal(): Plugin {
return {
name: 'buffer-global',
setup({ onResolve, onLoad, onTransform }) {
onTransform({ filter: /\.html$/ }, (args) => {
const contents = args.contents.replace(
//,
`$&\n` +
`\n`,
)
return {
contents,
}
})
onResolve({ filter: new RegExp(BUFFER_PATH) }, (arg) => {
return {
path: BUFFER_PATH,
}
})
onLoad({ filter: new RegExp(BUFFER_PATH) }, async (arg) => {
const polyfill = await readFile(
require.resolve(
`@esbuild-plugins/node-globals-polyfill/Buffer.js`,
),
)
return {
contents: polyfill + `\nwindow.Buffer = Buffer;`,
loader: 'js',
}
})
},
}
}
================================================
FILE: bundless/src/plugins/css.ts
================================================
import { NodeResolvePlugin, resolveAsync } from '@esbuild-plugins/all'
import { transform } from 'esbuild'
import escapeStringRegexp from 'escape-string-regexp'
import hash_sum from 'hash-sum'
import path from 'path'
import fs from 'fs-extra'
import { CLIENT_PUBLIC_PATH, hmrPreamble } from '../constants'
import { PluginHooks } from '../plugins-executor'
import { osAgnosticPath } from '../utils'
const CSS_UTILS_PATH = '_bundless_css_utils.js'
/*
importing a css module file does 2 things
- import a js file that calls ensureCssLink and exports the class names as js object
- add the link in the html entry at build time
This way even if you load the app from a different entrypoint and you change location via history API, you get ensureCssLink that adds the link to the html
Global css files instead must be all loaded at once because its classnames are not unique
*/
export function CssPlugin({} = {}) {
return {
name: 'css',
setup: ({
ctx: { root, config, isBuild, graph },
onLoad,
onResolve,
onTransform,
}: PluginHooks) => {
// TODO use custom resolver that adds the .js extension to css paths?
async function cssResolver(args) {
try {
const res = await resolveAsync(args.path, {
basedir: args.resolveDir,
})
const virtualPath = res + '.cssjs'
if (res) {
return {
path: virtualPath,
}
}
} catch {}
}
onResolve({ filter: /\.css$/ }, cssResolver)
const cssExtensions = Object.keys(config.loader || {})
.filter((k) => config.loader?.[k] === 'css')
.map(escapeStringRegexp)
if (cssExtensions.length) {
onResolve(
{
filter: new RegExp(
'(' + cssExtensions.join('|') + ')$',
),
},
cssResolver,
)
}
onLoad({ filter: /\.cssjs$/ }, async (args) => {
try {
const css = await (
await fs.readFile(args.path.replace(/\.cssjs$/, ''))
).toString()
// const id = hash_sum(args.path)
let contents = await codegenCssForDev(css, args.path)
if (!isBuild) {
contents = hmrPreamble + '\n' + contents
}
return { contents, loader: 'js' }
} catch {}
})
// needed for other plugins that return css and are not resolved by this plugin
onTransform({ filter: /\.css$/ }, async (args) => {
let contents = await codegenCssForDev(args.contents, args.path)
if (!isBuild) {
contents = hmrPreamble + '\n' + contents
}
return { contents, loader: 'js' }
})
onResolve(
{ filter: new RegExp(escapeStringRegexp(CSS_UTILS_PATH)) },
(args) => {
return {
path: path.resolve(root, cssUtilsTemplate),
}
},
)
onLoad(
{ filter: new RegExp(escapeStringRegexp(CSS_UTILS_PATH)) },
(args) => {
return {
contents: cssUtilsTemplate,
loader: 'js',
}
},
)
},
}
}
const cssUtilsTemplate = `
function ensureCss(href) {
const existingLinkTags = document.getElementsByTagName('link')
for (let i = 0; i < existingLinkTags.length; i++) {
if (tag.rel === 'stylesheet' && tag.getAttribute('href') === href) {
return
}
}
const linkTag = document.createElement('link')
linkTag.rel = 'stylesheet'
linkTag.type = 'text/css'
linkTag.href = href
const head = document.getElementsByTagName('head')[0]
head.appendChild(linkTag)
}
`
export async function codegenCssForDev(
css: string,
sourcefile: string,
modules?: Record,
) {
let code = `
const css = ${JSON.stringify(css)};
if (typeof document !== 'undefined') {
import.meta.hot.accept();
import.meta.hot.dispose(() => {
document.head.removeChild(styleEl);
});
const styleEl = document.createElement("style");
const codeEl = document.createTextNode(css);
styleEl.type = 'text/css';
styleEl.appendChild(codeEl);
document.head.appendChild(styleEl);
}
`
if (modules) {
const transformed = await transform(JSON.stringify(modules), {
format: 'esm',
loader: 'json',
sourcefile,
})
code += transformed.code
} else {
code += `export default css`
}
return code
}
export function codegenCssForProduction(
cssPath: string,
modules?: Record,
): string {
let code =
hmrPreamble +
`
import { ensureCSS } from '${CSS_UTILS_PATH}'
if (typeof window !== 'undefined') {
ensureCSS(${JSON.stringify(cssPath)})
}
`
return code
}
================================================
FILE: bundless/src/plugins/env.ts
================================================
import dotenv from 'dotenv'
import dotenvExpand from 'dotenv-expand'
import findUp from 'find-up'
import fs from 'fs-extra'
import path from 'path'
import { logger } from '../logger'
import { PluginHooks } from '../plugins-executor'
export function EnvPlugin({
envFiles = [] as string[],
env = {} as Record,
findUp: isFindUp = false,
} = {}) {
return {
name: 'env',
setup: ({ initialOptions, ctx: { root } }: PluginHooks) => {
let define = {}
for (let _envFile of envFiles) {
let envFile
if (fs.existsSync(path.resolve(root, _envFile))) {
envFile = path.resolve(root, _envFile)
} else if (isFindUp) {
envFile = findUp.sync(_envFile, { cwd: root }) || ''
}
if (!envFile) {
logger.warn(`Cannot find env file '${_envFile}'`)
continue
}
const data = fs.readFileSync(envFile).toString()
const parsed = dotenv.parse(data, {
debug: !!process.env.DEBUG || undefined,
})
// let environment variables use each other
dotenvExpand({
parsed,
// prevent process.env mutation
ignoreProcessEnv: true,
} as any)
for (const k in parsed) {
define[`process.env.${k}`] = JSON.stringify(parsed[k])
}
}
for (const k in env) {
define[`process.env.${k}`] = JSON.stringify(env[k])
}
Object.assign(initialOptions.define, define)
},
}
}
================================================
FILE: bundless/src/plugins/esbuild.ts
================================================
import chalk from 'chalk'
import * as esbuild from 'esbuild'
import { Loader, Message, TransformOptions } from 'esbuild'
import path from 'path'
import { Config } from '../config'
import { OnTransformResult, PluginHooks } from '../plugins-executor'
import { generateDefineObject } from '../prebundle/esbuild'
import { generateCodeFrame } from '../utils'
export function EsbuildTransformPlugin({} = {}) {
return {
name: 'esbuild-transform',
setup: ({ onTransform, onClose, ctx: { config } }: PluginHooks) => {
onTransform({ filter: /\.(tsx?|jsx)$/ }, async (args) => {
// do not transpile again if already transpiled
if (args.loader === 'js') {
return
}
return transform({
src: args.contents,
filePath: args.path,
config,
})
})
},
}
}
const JsxPresets: Record<
string,
Pick
> = {
vue: { jsxFactory: 'jsx', jsxFragment: 'Fragment' },
preact: { jsxFactory: 'h', jsxFragment: 'Fragment' },
react: {},
// react: { jsxFactory: 'React.createElement', }, // use esbuild default
}
export function resolveJsxOptions(options: Config['jsx'] = 'react') {
if (typeof options === 'string') {
if (!(options in JsxPresets)) {
console.error(`unknown jsx preset: '${options}'.`)
}
return JsxPresets[options] || {}
} else if (options) {
return {
jsxFactory: options.factory,
jsxFragment: options.fragment,
}
}
}
// transform used in server plugins with a more friendly API
export const transform = async ({
src,
filePath,
loader,
config,
}: {
src: string
filePath: string
config?: Config
loader?: esbuild.Loader
exitOnFailure?: boolean
}): Promise => {
const options: TransformOptions = {
loader: loader || (path.extname(filePath).slice(1) as Loader),
logLevel: 'error',
sourcemap: true,
// format: 'esm', // passing format reorders exports https://github.com/evanw/esbuild/issues/710
// ensure source file name contains full query
sourcefile: filePath,
// TODO use define object here? this way it works the same as in build, but this way it won't work when using another transformer
target: 'es2020',
...resolveJsxOptions(config?.jsx),
}
try {
const result = await esbuild.transform(src, options)
let contents = result.code
// if transpiling (j|t)sx file, inject the imports for the jsx helper and
// Fragment.
if (filePath.endsWith('x')) {
// if (!jsxOption || jsxOption === 'vue') {
// code +=
// `\nimport { jsx } from '${vueJsxPublicPath}'` +
// `\nimport { Fragment } from 'vue'`
// }
if (config?.jsx === 'preact') {
contents += `\nimport { h, Fragment } from 'preact'`
}
}
return {
contents,
map: JSON.parse(result.map),
}
} catch (e) {
if (e.errors) {
e.errors.forEach((m: Message) => printMessage(m, src))
} else {
console.error(e)
}
throw new Error(
`Error while transforming ${filePath} with esbuild: ${e}`,
)
}
}
function printMessage(m: Message, code: string) {
console.error(chalk.yellow(m.text))
if (m.location) {
const lines = code.split(/\r?\n/g)
const line = Number(m.location.line)
const column = Number(m.location.column)
const offset =
lines
.slice(0, line - 1)
.map((l) => l.length)
.reduce((total, l) => total + l + 1, 0) + column
console.error(generateCodeFrame(code, offset, offset + 1))
}
}
================================================
FILE: bundless/src/plugins/hmr-client.ts
================================================
import fs from 'fs-extra'
import { CLIENT_PUBLIC_PATH, hmrClientNamespace } from '../constants'
import { PluginHooks } from '../plugins-executor'
import { generateDefineObject } from '../prebundle/esbuild'
export const clientFilePath = require.resolve('../../esm/client/template.js')
export const sourceMapSupportPath =
'__source-map-support.js?namespace=source-map-support'
export function HmrClientPlugin({ getPort }) {
return {
name: 'hmr-client',
setup: ({
onLoad,
onTransform,
ctx: { config, root },
}: PluginHooks) => {
onTransform({ filter: /\.html$/ }, (args) => {
const contents = args.contents.replace(
//,
`$&\n` +
`\n`,
)
return {
contents,
}
})
onLoad(
{ filter: /.*/, namespace: 'source-map-support' },
async () => {
return {
contents: await fs.readFile(
require.resolve(
'source-map-support/browser-source-map-support.js',
),
),
}
},
)
onLoad(
{ filter: /.*/, namespace: hmrClientNamespace },
async (args) => {
const defines = generateDefineObject({ config })
const clientCode = fs
.readFileSync(clientFilePath, 'utf-8')
.replace(
`__DEFINES__`,
'{\n' +
Object.keys(defines)
.sort((a, b) => a.length - b.length)
.map(
(k) =>
` ${JSON.stringify(k)}: ${
defines[k]
},`,
)
.join('\n') +
'\n}',
)
.replace(`//# sourceMappingURL=`, '//')
let socketPort: number | string = getPort()
// infer on client by default
let socketProtocol: any = null
let socketHostname: any = null
let socketTimeout = 30000
const hmrConfig = config.server?.hmr || true
if (hmrConfig && typeof hmrConfig === 'object') {
// hmr option has highest priory
socketProtocol = hmrConfig.protocol || null
socketHostname = hmrConfig.hostname || null
socketPort = hmrConfig.port || getPort()
if (hmrConfig.timeout) {
socketTimeout = hmrConfig.timeout
}
if (hmrConfig.path) {
socketPort = `${socketPort}/${hmrConfig.path}`
}
}
return {
contents: clientCode
.replace(
`__HMR_PROTOCOL__`,
JSON.stringify(socketProtocol),
)
.replace(
`__HMR_HOSTNAME__`,
JSON.stringify(socketHostname),
)
.replace(`__HMR_PORT__`, JSON.stringify(socketPort))
.replace(
`__HMR_ENABLE_OVERLAY__`,
JSON.stringify(true),
)
.replace(
`__HMR_TIMEOUT__`,
JSON.stringify(socketTimeout),
),
}
},
)
},
}
}
================================================
FILE: bundless/src/plugins/html-ingest.ts
================================================
import fs from 'fs'
import posthtml, { Node, Plugin as PosthtmlPlugin } from 'posthtml'
import path from 'path'
import { Plugin } from '../plugins-executor'
import { cleanUrl } from '../utils'
import slash from 'slash'
const NAME = 'html-ingest'
interface Options {
name?: string
root: string // to resolve paths in case the html page is not in root
transformImportPath?: (importPath: string) => string
// emitHtml?: (arg: { path: string; html: string }) => Promise
}
/**
* Let you use html files as entrypoints for esbuild
*/
export function HtmlIngestPlugin({
name = NAME,
root,
transformImportPath,
}: Options): Plugin {
return {
name,
setup: function setup({ onLoad, onTransform, onResolve }) {
onTransform({ filter: /\.html$/ }, async (args) => {
try {
const html = args.contents
const jsUrls = await getHtmlScriptsUrls(html)
// const folder = path.relative(root, path.dirname(args.path))
const pathToRoot = slash(
path.relative(path.dirname(args.path), root),
)
const contents = jsUrls
.map((importPath) => {
// src='/file.js' -> ../../file.js
if (importPath.startsWith('/')) {
importPath = path.posix.join(
pathToRoot,
'.' + importPath,
)
}
// src='file.js' -> ./file.js
if (bareImportRE.test(importPath)) {
importPath = './' + importPath
}
return importPath
})
.map((x) =>
transformImportPath ? transformImportPath(x) : x,
)
.map((importPath) => `export * from '${importPath}'`)
.join('\n')
return {
loader: 'js',
contents,
}
} catch (e) {
throw new Error(`Cannot transform html ${args.path}, ${e}`)
}
})
},
}
}
export async function getHtmlScriptsUrls(html: string) {
const urls: string[] = []
const transformer = posthtml([
(tree) => {
tree.walk((node) => {
if (
node &&
node.tag === 'script' &&
node.attrs &&
node.attrs['type'] === 'module' &&
node.attrs['src'] &&
isRelative(node.attrs['src'])
) {
urls.push(node.attrs['src'])
}
return node
})
},
])
try {
await transformer.process(html)
} catch (e) {
throw new Error(`Cannot process html with posthtml: ${e}\n${html}`)
}
return urls.filter(Boolean)
}
const bareImportRE = /^[^\/\.]/
function isRelative(x: string) {
x = cleanUrl(x)
return bareImportRE.test(x) || x.startsWith('.') || x.startsWith('/')
}
================================================
FILE: bundless/src/plugins/html-resolver.ts
================================================
import fs from 'fs-extra'
import path from 'path'
import { PluginHooks } from '../plugins-executor'
export function HtmlResolverPlugin({} = {}) {
return {
name: 'html-resolver',
setup: ({ ctx: { root }, onLoad, onResolve }: PluginHooks) => {
onResolve({ filter: /\.html/ }, async (args) => {
args.path = path.resolve(root, args.path)
var resolved = path.resolve(args.resolveDir || root, args.path)
if (resolved && fs.existsSync(resolved)) {
return {
path: resolved,
}
}
const relativePath = path.relative(root, args.path)
var resolved = path.resolve(
path.resolve(root, path.join('public', relativePath)),
)
if (resolved && fs.existsSync(resolved)) {
return {
path: resolved,
}
}
return null
})
onLoad({ filter: /\.html$/ }, async (args) => {
try {
const realFilePath = args.path // .replace('.html.js', '.html')
const html = await (
await fs.readFile(realFilePath, {
encoding: 'utf-8',
})
).toString()
return {
contents: html,
loader: 'html' as any,
}
} catch (e) {
return null
throw new Error(`Cannot load ${args.path}, ${e}`)
}
})
},
}
}
================================================
FILE: bundless/src/plugins/html-transform.ts
================================================
import posthtml, { Plugin } from 'posthtml'
import { PluginHooks } from '../plugins-executor'
import { cleanUrl } from '../utils'
export function HtmlTransformUrlsPlugin({
transforms,
}: {
transforms: Plugin[]
}) {
return {
name: 'html-transform-urls',
setup: ({ onTransform }: PluginHooks) => {
onTransform({ filter: /\.html$/ }, async (args) => {
const transformer = posthtml([...transforms])
const result = await transformer.process(args.contents)
const contents = result.html
return { contents }
})
},
}
}
// TODO transformer to rewrite inline script imports
================================================
FILE: bundless/src/plugins/index.ts
================================================
export { EsbuildTransformPlugin } from './esbuild'
export { RewritePlugin } from './rewrite'
export { CssPlugin } from './css'
export { ResolveSourcemapPlugin } from './resolve-sourcemaps'
export { HmrClientPlugin } from './hmr-client'
export { JSONPlugin } from './json'
export { AssetsPlugin } from './assets'
export { UrlResolverPlugin } from './url-resolver'
export { HtmlTransformUrlsPlugin } from './html-transform'
export { HtmlResolverPlugin } from './html-resolver'
export { HtmlIngestPlugin } from './html-ingest'
export { SourceMapSupportPlugin } from './source-map-support'
export { EnvPlugin } from './env'
export { NodeBufferGlobal } from './buffer'
export {
NodeModulesPolyfillPlugin,
NodeResolvePlugin,
NodeGlobalsPolyfillPlugin
} from '@esbuild-plugins/all'
================================================
FILE: bundless/src/plugins/json.ts
================================================
import { NodeResolvePlugin } from '@esbuild-plugins/all'
import { transform } from 'esbuild'
import { PluginHooks } from '../plugins-executor'
import { readFile } from '../utils'
export function JSONPlugin({} = {}) {
return {
name: 'json',
setup: (hooks: PluginHooks) => {
const { onLoad, onResolve } = hooks
NodeResolvePlugin({
name: 'json-node-resolve',
isExtensionRequiredInImportPath: true,
extensions: ['.json'],
}).setup({
...hooks,
onLoad() {},
})
onLoad({ filter: /\.json$/ }, async (args) => {
const json = await readFile(args.path)
const transformed = await transform(json, {
format: 'esm',
loader: 'json',
sourcefile: args.path,
})
const contents = transformed.code
return { contents }
})
},
}
}
================================================
FILE: bundless/src/plugins/resolve-sourcemaps.ts
================================================
import chalk from 'chalk'
import fs from 'fs'
import path from 'path'
import { RawSourceMap } from 'source-map'
import { PluginHooks } from '../plugins-executor'
import { fileToImportPath, jsTypeRegex, readFile } from '../utils'
const sourcemapRegex = /\/\/#\ssourceMappingURL=([\w\d-_\.]+)\n*$/
export function ResolveSourcemapPlugin({} = {}) {
return {
name: 'resolve-sourcemaps',
setup: ({
onTransform,
pluginsExecutor,
ctx: { root },
}: PluginHooks) => {
onTransform({ filter: jsTypeRegex }, async (args) => {
let contents = args.contents
const match = contents.match(sourcemapRegex)
if (!match) {
return
}
let filePath = match[1]
if (!filePath || filePath.startsWith('data:')) {
// TODO skip other data: non base64 formats in sourcemaps
return
}
if (!filePath.startsWith('.') && !filePath.startsWith('/')) {
filePath = './' + filePath
}
const resolved = await pluginsExecutor.resolve({
importer: args.path,
path: filePath.trim(),
namespace: '',
resolveDir: path.dirname(args.path),
})
if (!resolved?.path) {
return
}
contents = contents.replace(
sourcemapRegex,
`//# sourceMappingURL=${fileToImportPath(
root,
resolved?.path,
)}`,
)
return {
contents,
}
})
},
}
}
================================================
FILE: bundless/src/plugins/rewrite/__snapshots__/commonjs.test.ts.snap
================================================
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rewrite commonjs imports 0 "import React from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const React = react_cjsImport0 && react_cjsImport0.__esModule ? react_cjsImport0.default : react_cjsImport0;"`;
exports[`rewrite commonjs imports 1 "import * as React from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const React = {default: react_cjsImport0, ...(typeof react_cjsImport0 === 'object' && react_cjsImport0)};"`;
exports[`rewrite commonjs imports 2 "import React, { useState } from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const React = react_cjsImport0 && react_cjsImport0.__esModule ? react_cjsImport0.default : react_cjsImport0; const useState = react_cjsImport0[\\"useState\\"];"`;
exports[`rewrite commonjs imports 3 "import { useState } from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const useState = react_cjsImport0[\\"useState\\"];"`;
exports[`rewrite commonjs imports 4 "import { useState, useEffect } from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const useState = react_cjsImport0[\\"useState\\"]; const useEffect = react_cjsImport0[\\"useEffect\\"];"`;
exports[`rewrite commonjs imports 5 "import { useState as something } from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const something = react_cjsImport0[\\"useState\\"];"`;
exports[`rewrite commonjs imports 6 "import { useState as something, useEffect } from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const something = react_cjsImport0[\\"useState\\"]; const useEffect = react_cjsImport0[\\"useEffect\\"];"`;
exports[`rewrite commonjs imports 7 "import { useState as something, useEffect as alias } from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const something = react_cjsImport0[\\"useState\\"]; const alias = react_cjsImport0[\\"useEffect\\"];"`;
exports[`rewrite commonjs imports 8 "import { default as Default } from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const Default = react_cjsImport0 && react_cjsImport0.__esModule ? react_cjsImport0.default : react_cjsImport0;"`;
exports[`rewrite commonjs imports 9 "import { default as Default, useEffect } from 'react'" 1`] = `"import react_cjsImport0 from \\"react\\"; const Default = react_cjsImport0 && react_cjsImport0.__esModule ? react_cjsImport0.default : react_cjsImport0; const useEffect = react_cjsImport0[\\"useEffect\\"];"`;
================================================
FILE: bundless/src/plugins/rewrite/commonjs.test.ts
================================================
import { parse } from '../../utils'
import { transformCjsImport } from './commonjs'
describe('rewrite commonjs imports', () => {
const cases = [
`import React from 'react'`,
`import * as React from 'react'`,
`import React, { useState } from 'react'`,
`import { useState } from 'react'`,
`import { useState, useEffect } from 'react'`,
`import { useState as something } from 'react'`,
`import { useState as something, useEffect } from 'react'`,
`import { useState as something, useEffect as alias } from 'react'`,
`import { default as Default } from 'react'`,
`import { default as Default, useEffect } from 'react'`,
]
for (let [i, testCase] of cases.entries()) {
test(`${i} "${testCase}"`, () => {
const res = transformCjsImport(testCase, 'react', 'react', 0)
expect(res).not.toContain('\n')
parse(res) // check that it's valid code
expect(res).toMatchSnapshot()
})
}
})
================================================
FILE: bundless/src/plugins/rewrite/commonjs.ts
================================================
import { ImportDeclaration } from '@babel/types'
import fs from 'fs-extra'
import { isPlainObject } from 'lodash'
import memoize from 'micro-memoize'
import path from 'path'
import { COMMONJS_ANALYSIS_PATH, WEB_MODULES_PATH } from '../../constants'
import { logger } from '../../logger'
import { onResolveLock } from '../../serve'
import { makeLegalIdentifier, osAgnosticPath, parse } from '../../utils'
export interface OptimizeAnalysisResult {
isCommonjs: { [name: string]: true }
}
/**
* read analysis result from optimize step
* If we can't find analysis result, return null
* (maybe because user set optimizeDeps.auto to false)
*/
export const getAnalysis = memoize(function getAnalysis(
root: string,
): OptimizeAnalysisResult | null {
let analysis: OptimizeAnalysisResult | null
try {
analysis = fs.readJsonSync(path.resolve(root, COMMONJS_ANALYSIS_PATH))
} catch (error) {
logger.debug(
`Cannot find commonjs analysis at ${path.resolve(
root,
COMMONJS_ANALYSIS_PATH,
)}`,
)
analysis = null
}
if (analysis && !isPlainObject(analysis.isCommonjs)) {
throw new Error(`invalid ${COMMONJS_ANALYSIS_PATH}`)
}
logger.debug(
`Got new commonjs analysis: ${JSON.stringify(
analysis?.isCommonjs,
null,
4,
)}`,
)
return analysis
})
export function clearCommonjsAnalysisCache() {
logger.debug(`Invalidating commonjs cache`)
getAnalysis.cache.keys.length = 0
getAnalysis.cache.values.length = 0
}
export function isOptimizedCjs(root: string, filename: string) {
if (!onResolveLock.isReady) {
throw new Error(
`Cannot call isOptimizedCjs when onResolveLock is locked!`,
)
}
const analysis = getAnalysis(root)
if (!analysis) {
return false
}
const isCommonjs = !!analysis.isCommonjs[osAgnosticPath(filename, root)]
return isCommonjs
}
type ImportNameSpecifier = { importedName: string; localName: string }
// todo if module has __esModule and there is only a default import, transform to .default, -> const imported = realImport.__esModule ? realImport.default : realImport
export function transformCjsImport(
exp: string,
id: string,
resolvedPath: string,
importIndex: number,
): string {
const ast = parse(exp)[0] as ImportDeclaration
const importNames = getImportNames(ast)
return generateCjsImport(importNames, id, resolvedPath, importIndex)
}
function getImportNames(ast: ImportDeclaration) {
const importNames: ImportNameSpecifier[] = []
ast.specifiers.forEach((obj) => {
if (
obj.type === 'ImportSpecifier' &&
obj.imported.type === 'Identifier'
) {
const importedName = obj.imported.name
const localName = obj.local.name
importNames.push({ importedName, localName })
} else if (obj.type === 'ImportDefaultSpecifier') {
importNames.push({
importedName: 'default',
localName: obj.local.name,
})
} else if (obj.type === 'ImportNamespaceSpecifier') {
importNames.push({ importedName: '*', localName: obj.local.name })
}
})
return importNames
}
function generateCjsImport(
importNames: ImportNameSpecifier[],
id: string,
resolvedPath: string,
importIndex: number,
): string {
// If there is multiple import for same id in one file,
// importIndex will prevent the cjsModuleName to be duplicate
const cjsModuleName = makeLegalIdentifier(`${id}_cjsImport${importIndex}`)
const lines: string[] = [`import ${cjsModuleName} from "${resolvedPath}";`]
importNames.forEach(({ importedName, localName }) => {
// __esModule means the module has been compiled from ESM: ESM -> commonjs -> ESM
// we consider commonjs all modules with only a default export, but if the module has been compiled from ESM, it will contain double default export: default.default
if (importedName === 'default') {
lines.push(
`const ${localName} = ${cjsModuleName} && ${cjsModuleName}.__esModule ? ${cjsModuleName}.default : ${cjsModuleName};`,
)
} else if (importedName === '*') {
lines.push(
`const ${localName} = {default: ${cjsModuleName}, ...(typeof ${cjsModuleName} === 'object' && ${cjsModuleName})};`,
)
} else {
lines.push(
`const ${localName} = ${cjsModuleName}["${importedName}"];`,
)
}
})
return lines.join(' ')
}
// adds the default export to the namespace in case this is an iterable object, this is to support the case `import * as namespace from 'mod'; namespace.default()`
// TODO namespace imports can be polluted in case default import is an object and user is doing import * on a ES module with only a default export, this can be solved adding isCommonjs to esbuild metafile
export function generateNamespaceExport(mId: string) {
return `({...${mId}, ...(${mId}.default instanceof Object && ${mId}.default.constructor === Object && m.default)})`
}
================================================
FILE: bundless/src/plugins/rewrite/index.ts
================================================
export * from './rewrite'
================================================
FILE: bundless/src/plugins/rewrite/rewrite.ts
================================================
import chalk from 'chalk'
import { ImportSpecifier, parse as parseImports } from 'es-module-lexer'
import MagicString from 'magic-string'
import path from 'path'
import { CLIENT_PUBLIC_PATH, hmrPreamble } from '../../constants'
import { HmrGraph } from '../../hmr-graph'
import { logger } from '../../logger'
import { PluginHooks, PluginsExecutor } from '../../plugins-executor'
import { onResolveLock } from '../../serve'
import {
appendQuery,
cleanUrl,
fileToImportPath,
isExternalUrl,
jsTypeRegex,
osAgnosticPath,
} from '../../utils'
import {
generateNamespaceExport,
isOptimizedCjs,
transformCjsImport,
} from './commonjs'
export function RewritePlugin({ filter = jsTypeRegex } = {}) {
return {
name: 'rewrite',
setup: ({
onTransform,
pluginsExecutor,
ctx: { graph, config, root, isBuild },
}: PluginHooks) => {
if (config.platform !== 'browser') {
return
}
if (isBuild || !graph) {
return
}
onTransform({ filter }, async (args) => {
const { contents, map } = await rewriteImports({
graph,
namespace: args.namespace || 'file',
importer: args.path,
root,
pluginsExecutor,
source: args.contents,
})
return {
contents, // TODO module rewrite needs not need sourcemaps? How?
map,
}
})
},
}
}
export async function rewriteImports({
source,
importer,
graph,
pluginsExecutor,
namespace,
root,
}: {
source: string
namespace: string
importer: string
pluginsExecutor: PluginsExecutor
root: string
graph: HmrGraph
}): Promise<{ contents: string; map?: any }> {
// strip UTF-8 BOM
if (source.charCodeAt(0) === 0xfeff) {
source = source.slice(1)
}
const relativeImporter = osAgnosticPath(importer, root)
// TODO how are computed files path removed?
graph.ensureEntry(importer)
try {
await onResolveLock.wait()
let imports: ImportSpecifier[] = []
try {
imports = parseImports(source)[0]
} catch (e) {
throw new Error(
`Failed to parse ${chalk.cyan(
importer,
)} for import rewrite.\nIf you are using ` +
`JSX, make sure to named the file with the .jsx extension.`,
)
}
const isHmrEnabled = source.includes('import.meta.hot')
const hasEnv = source.includes('import.meta.env')
if (!imports.length && !isHmrEnabled && !hasEnv) {
return { contents: source }
}
const magicString = new MagicString(source)
if (isHmrEnabled) {
magicString.prepend(hmrPreamble)
}
const currentNode = graph.ensureEntry(importer, {
isHmrEnabled,
importees: new Set(),
})
for (let i = 0; i < imports.length; i++) {
const {
s: start,
e: end,
d: dynamicIndex,
ss: expStart,
se: expEnd,
} = imports[i]
let id = source.substring(start, end)
const hasIgnore = /\/\*\s*@bundless-ignore\s*\*\//.test(id)
let hasLiteralDynamicId = false
const isDynamicImport = dynamicIndex >= 0
if (isDynamicImport) {
id = id.replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '')
const literalIdMatch = id.match(
/^\s*(?:'([^']+)'|"([^"]+)")\s*$/,
)
if (literalIdMatch) {
hasLiteralDynamicId = true
id = literalIdMatch[1] || literalIdMatch[2]
}
}
if (dynamicIndex === -1 || hasLiteralDynamicId) {
// do not rewrite external imports
if (isExternalUrl(id)) {
continue
}
const resolveResult = await pluginsExecutor.resolve({
importer,
namespace,
resolveDir: path.dirname(importer),
path: id,
})
if (!resolveResult || !resolveResult.path) {
// do not fail on unresolved dynamic imports
if (isDynamicImport) {
logger.log(
`Cannot resolve '${id}' from '${relativeImporter}'`,
)
continue
}
throw new Error(
`Cannot resolve '${id}' from '${relativeImporter}'`,
)
}
if (resolveResult?.pluginData) {
logger.warn(
`esbuild pluginData is not supported by bundless, used by plugin ${resolveResult.pluginName}`,
)
}
let resolvedImportPath = ''
const isVirtual =
resolveResult.namespace &&
resolveResult.namespace !== 'file'
// handle bare imports like node builtins, virtual files, ...
if (isVirtual || !path.isAbsolute(resolveResult.path || '')) {
resolvedImportPath = '/' + resolveResult.path
} else {
resolvedImportPath = fileToImportPath(
root,
resolveResult?.path || '',
)
}
const newNamespace = encodeURIComponent(
resolveResult.namespace || namespace,
)
resolvedImportPath = appendQuery(
resolvedImportPath,
`namespace=${newNamespace}`,
)
// TODO maybe also register virtual files, ok onFileChange will never get triggered but maybe there is virtual css file or stuff like that that needs to be updated?
if (!isVirtual) {
const importeeNode = graph.ensureEntry(resolveResult.path)
// do not use stale modules
resolvedImportPath = appendQuery(
resolvedImportPath,
`t=${
importeeNode.hash + importeeNode.lastUsedTimestamp
}`,
)
}
if (resolvedImportPath !== id) {
if (isOptimizedCjs(root, resolveResult.path || '')) {
if (dynamicIndex === -1) {
const exp = source.substring(expStart, expEnd)
const replacement = transformCjsImport(
exp,
id,
resolvedImportPath,
i,
)
magicString.overwrite(expStart, expEnd, replacement)
} else if (hasLiteralDynamicId) {
// rewrite `import('package')` to
// import('/package').then(m=>({...((m.default instanceof Object && m.default.constructor === Object) && m.default), ...m})));
magicString.overwrite(
dynamicIndex,
end + 1,
`import('${resolvedImportPath}').then(m=>${generateNamespaceExport(
'm',
)})`,
)
}
} else {
magicString.overwrite(
start,
end,
hasLiteralDynamicId
? `'${resolvedImportPath}'`
: resolvedImportPath,
)
}
}
// save the import chain for hmr analysis
const cleanImportee = cleanUrl(resolvedImportPath)
if (
// no need to track hmr client or module dependencies
cleanImportee !== CLIENT_PUBLIC_PATH
) {
currentNode.importees.add(cleanImportee)
}
} else if (id !== 'import.meta' && !hasIgnore) {
logger.log(
chalk.yellow(
`Cannot rewrite dynamic import(${id}) in ${relativeImporter}.`,
),
)
}
}
return {
contents: magicString.toString(),
map: undefined, // do i really need sourcemaps? code is readable enough
}
} catch (e) {
e.message = `Invalid module ${relativeImporter}\n` + e
throw e
}
}
================================================
FILE: bundless/src/plugins/source-map-support.ts
================================================
import fs from 'fs-extra'
import { CLIENT_PUBLIC_PATH } from '../constants'
import { PluginHooks } from '../plugins-executor'
export const sourceMapSupportPath =
'__source-map-support.js?namespace=source-map-support'
export function SourceMapSupportPlugin({} = {}) {
return {
name: 'hmr-client',
setup: ({
onLoad,
onTransform,
ctx: { config, root },
}: PluginHooks) => {
// TODO reenable source map support
return
onTransform({ filter: /\.html$/ }, (args) => {
const contents = args.contents.replace(
//,
`$&\n` +
`\n` +
`\n`,
)
return {
contents,
}
})
onLoad(
{ filter: /.*/, namespace: 'source-map-support' },
async () => {
return {
contents: await fs.readFile(
require.resolve(
'source-map-support/browser-source-map-support.js',
),
),
}
},
)
},
}
}
================================================
FILE: bundless/src/plugins/url-resolver.ts
================================================
import { NodeResolvePlugin } from '@esbuild-plugins/all'
import { PluginHooks } from '../plugins-executor'
import { importPathToFile, readFile } from '../utils'
import url from 'url'
import { logger } from '../logger'
import qs from 'qs'
export function UrlResolverPlugin({} = {}) {
return {
name: 'url-resolver',
setup: ({ ctx: { root }, onResolve }: PluginHooks) => {
onResolve({ filter: /\?/ }, async (arg) => {
if (!arg.path.includes('?')) {
return
}
const parsed = url.parse(arg.path)
if (!parsed.pathname) {
throw new Error('no pathname in ' + arg.path)
}
const query = qs.parse(parsed.query || '')
if (
query.namespace &&
typeof query.namespace === 'string' &&
query.namespace !== 'file'
) {
// logger.log(`Removed query from path ${arg.path}`)
return {
path: parsed.pathname.slice(1), // TODO write a spec for virtual files in url behaviour
namespace: query.namespace,
}
}
return {
path: importPathToFile(root, parsed.pathname),
}
})
},
}
}
================================================
FILE: bundless/src/plugins-executor.ts
================================================
import { O_TRUNC } from 'constants'
import * as esbuild from 'esbuild'
import { cloneDeep } from 'lodash'
import { promises } from 'fs-extra'
import { Config } from './config'
import url from 'url'
import fs from 'fs-extra'
import { HmrGraph } from './hmr-graph'
import { logger } from './logger'
import { flatten, osAgnosticPath } from './utils'
import qs from 'qs'
import { mergeSourceMap } from './utils/sourcemaps'
import path from 'path'
import { ansiChart } from './utils/profiling'
import { FSWatcher } from 'chokidar'
import { resolveAsync } from '@esbuild-plugins/all'
import { MAIN_FIELDS } from './constants'
export interface Plugin {
name: string
modulesToPrebundle?: string[]
enforce?: 'pre' | 'post'
setup: (build: PluginHooks) => void
}
type OnResolveCallback = (
args: esbuild.OnResolveArgs,
) => Maybe>>
type OnLoadCallback = (
args: esbuild.OnLoadArgs,
) => Maybe>>
type OnTransformCallback = (
args: OnTransformArgs,
) => Maybe>>
type OnCloseCallback = () => void | Promise
export interface PluginsExecutorCtx {
config: Config
root: string
graph?: HmrGraph
isBuild: boolean
watcher?: FSWatcher
}
export interface PluginHooks extends esbuild.PluginBuild {
ctx: PluginsExecutorCtx
pluginsExecutor: PluginsExecutor
onResolve(
options: esbuild.OnResolveOptions,
callback: OnResolveCallback,
): void
onLoad(options: esbuild.OnLoadOptions, callback: OnLoadCallback): void
onTransform(
options: esbuild.OnLoadOptions,
callback: OnTransformCallback,
): void
onClose(options: any, callback: OnCloseCallback): void
}
export interface OnTransformArgs {
path: string
loader: esbuild.Loader
namespace?: string
contents: string
}
export interface OnTransformResult {
contents: string
map?: any
loader?: esbuild.Loader
}
type Maybe = x | undefined | null
type PluginInternalObject = {
name: string
options: { filter: RegExp; namespace?: string }
callback: CB
}
export type OnResolved = (
result: esbuild.OnResolveResult & { importer: string },
) => Promise> | Maybe
// TODO let plugins modify the options, pass an esbuild options as argument and you can access the mutated version as class instance
export class PluginsExecutor {
ctx: PluginsExecutorCtx
plugins: Plugin[]
isProfiling: boolean
onResolved?: OnResolved
initialOptions: esbuild.BuildOptions
private startingInitialOptions: esbuild.BuildOptions
private transforms: PluginInternalObject[] = []
private resolvers: PluginInternalObject[] = []
private loaders: PluginInternalObject[] = []
private closers: PluginInternalObject[] = []
constructor(_args: {
plugins: Array
ctx: PluginsExecutorCtx
initialOptions: esbuild.BuildOptions
isProfiling?: boolean
onResolved?: OnResolved
}) {
const {
ctx,
plugins,
isProfiling = false,
onResolved,
initialOptions,
} = _args
this.ctx = ctx
this.initialOptions = initialOptions
this.startingInitialOptions = cloneDeep(initialOptions)
this.onResolved = onResolved
this.plugins = plugins
this.isProfiling = isProfiling
for (let plugin of plugins) {
if (isProfiling) {
plugin = this.wrapPluginForProfiling(plugin)
}
const { name, setup } = plugin
setup({
ctx,
initialOptions,
pluginsExecutor: this,
onLoad: (options, callback) => {
this.loaders.push({ options, callback, name })
},
onResolve: (options, callback) => {
this.resolvers.push({ options, callback, name })
},
onTransform: (options, callback) => {
this.transforms.push({ options, callback, name })
},
onClose: (options, callback) => {
this.closers.push({ options, callback, name })
},
})
}
}
modulesToPrebundle() {
return flatten(this.plugins.map((p) => p.modulesToPrebundle || []))
}
private matches(
options: { filter: RegExp; namespace?: string },
arg: { path?: string; namespace?: string },
) {
if (!arg.path) {
return false
}
if (options.filter && !options.filter.test(arg.path)) {
return false
}
const optsNamespace = options.namespace || 'file'
const argNamespace = arg.namespace || 'file'
if (argNamespace !== optsNamespace) {
return false
}
return true
}
async load(arg: esbuild.OnLoadArgs): Promise> {
let result
for (let { callback, options, name } of this.loaders) {
if (this.matches(options, arg)) {
try {
logger.debug(
`loading '${osAgnosticPath(
arg.path,
this.ctx.root,
)}' with '${name}'`,
)
const newResult = await callback(arg)
if (newResult) {
result = newResult
if (!result.pluginName) {
result.pluginName = name
}
break
}
} catch (e) {
if (e && e?.message) {
e.plugin = name
}
throw e
}
}
}
if (result) {
return { ...result, namespace: result.namespace || 'file' }
}
}
async transform(arg: OnTransformArgs): Promise {
let result: OnTransformResult = { contents: arg.contents }
for (let { callback, options, name } of this.transforms) {
try {
if (this.matches(options, arg)) {
logger.debug(`transforming '${arg.path}' with '${name}'`)
const newResult = await callback(arg)
if (newResult?.contents != null) {
arg.contents = newResult.contents
result.contents = newResult.contents
}
if (newResult?.loader) {
arg.loader = newResult.loader
result.loader = newResult.loader
}
// merge with previous source maps
if (newResult?.map) {
if (result.map) {
result.map = mergeSourceMap(
result.map,
newResult.map,
)
} else {
result.map = newResult.map
}
}
}
} catch (e) {
if (e && e?.message) {
e.plugin = name
}
throw e
}
}
return result
}
/**
* Resolve filter should match on basename and not rely on absolute path, "virtual" could be passed as absolute paths from root: /path/to/virtual_file
*/
async resolve(
arg: Partial & { skipOnResolved?: boolean },
): Promise> {
let result
// support for resolving paths with queries
for (let { callback, options, name } of this.resolvers) {
if (this.matches(options, arg)) {
logger.debug(`resolving '${arg.path}' with '${name}'`)
const newResult = await callback({
importer: '',
namespace: 'file',
pluginData: undefined,
resolveDir: '',
path: '',
kind: 'import-statement', // TODO fix wrong kind in resolve
...arg,
})
if (newResult && newResult.path) {
logger.debug(
`resolved '${
arg.path
}' with '${name}' as '${osAgnosticPath(
newResult.path,
this.ctx.root,
)}'`,
)
result = newResult
if (!result.pluginName) {
result.pluginName = name
}
break
}
// break
}
}
if (result) {
result = { ...result, namespace: result.namespace || 'file' }
// register resolved modules that do not exist to real file paths, so that i can resolve them in onFileChange
if (this.ctx?.graph && arg.path && !fs.existsSync(result.path)) {
try {
const realPath = await resolveAsync(arg.path, {
basedir: arg.resolveDir || arg.importer,
mainFields: MAIN_FIELDS,
})
if (realPath) {
if (this.ctx.graph.realToFake[realPath]) {
this.ctx.graph.realToFake[realPath].add(result.path)
} else {
this.ctx.graph.realToFake[realPath] = new Set([
result.path,
])
}
}
} catch {}
}
if (!arg.skipOnResolved && this.onResolved) {
const newResult = await this.onResolved({
...result,
importer: arg.importer,
})
if (newResult) {
return newResult
}
}
return result
}
}
async close() {
let result
for (let { callback, options, name } of this.closers) {
logger.debug(`cleaning resources for '${name}'`)
await callback()
}
return result
}
async resolveLoadTransform({
path: p,
importer = '',
namespace = 'file',
expectedExtensions,
skipOnResolved,
}: {
path: string
importer?: string
namespace?: string
skipOnResolved?: boolean
expectedExtensions?: string[]
}): Promise<{ path?: string; contents?: string }> {
let resolveDir = path.dirname(p)
if (resolveDir === '/' || resolveDir === '.') {
resolveDir = ''
}
const resolved = await this.resolve({
importer,
namespace,
path: p,
resolveDir,
skipOnResolved,
})
if (resolved?.pluginData) {
logger.warn(
`pluginData is not supported by bundless, used by plugin ${resolved.pluginName}`,
)
}
if (!resolved || !resolved.path) {
return {}
}
if (
expectedExtensions &&
!expectedExtensions.includes(path.extname(resolved.path))
) {
return {}
}
const loaded = await this.load({
namespace: resolved.namespace || 'file',
path: resolved.path,
pluginData: undefined,
})
if (loaded?.pluginData) {
logger.warn(
`esbuild pluginData is not supported by bundless, used by plugin ${loaded.pluginName}`,
)
}
if (!loaded) {
return {}
}
const transformed = await this.transform({
contents: String(loaded.contents),
path: resolved.path,
loader: loaded.loader || 'default',
namespace: resolved.namespace || 'file',
})
if (!transformed) {
return { contents: String(loaded.contents), path: resolved.path }
}
return { contents: String(transformed.contents), path: resolved.path }
}
esbuildPlugins() {
return this.plugins.map((plugin, index) =>
this.wrapPluginForEsbuild(plugin),
)
}
profilingData: {
resolvers: Record
loaders: Record
transforms: Record
} = {
resolvers: {},
loaders: {},
transforms: {},
}
printProfilingResult() {
let str = '\n\nProfiling data:\n\n'
// console.log(this.profilingData)
const data = Object.keys(this.profilingData).map((k) => {
const timeConsume: number = Object.values(
this.profilingData[k],
).reduce(sum, 0) as any
return {
path: k,
timeConsume,
}
})
if (data.map((x) => x.timeConsume).reduce(sum, 0) === 0) {
return ''
}
str += ansiChart(data)
str += '\n\nResolvers\n\n'
const resolversData = Object.keys(this.profilingData.resolvers).map(
(pluginName) => {
return {
path: pluginName,
timeConsume: this.profilingData.resolvers[pluginName],
}
},
)
const opts = { limit: 3 }
str += ansiChart(resolversData, opts)
str += '\n\nLoaders\n\n'
const loadersData = Object.keys(this.profilingData.loaders).map(
(pluginName) => {
return {
path: pluginName,
timeConsume: this.profilingData.loaders[pluginName],
}
},
)
str += ansiChart(loadersData, opts)
str += '\n\nTransforms\n\n'
const transformsData = Object.keys(this.profilingData.transforms).map(
(pluginName) => {
return {
path: pluginName,
timeConsume: this.profilingData.transforms[pluginName],
}
},
)
str += ansiChart(transformsData, opts)
str += '\n'
return str
}
private wrapPluginForProfiling(plugin: Plugin): Plugin {
const pluginsExecutor: PluginsExecutor = this
const { profilingData: profiledData } = this
const { name } = plugin
function wrapMethod(method, type: string) {
return async (...args) => {
const timeStart = Date.now()
const res = await method(...args)
const delta = Date.now() - timeStart
profiledData[type][name] =
(profiledData[type][name] || 0) + delta
return res
}
}
return {
name,
setup(hooks) {
plugin.setup({
...hooks,
pluginsExecutor,
// wrap onLoad to execute other plugins transforms
onLoad: wrapMethod(hooks.onLoad, 'loaders'),
onResolve: wrapMethod(hooks.onResolve, 'resolvers'),
onTransform: wrapMethod(hooks.onTransform, 'transforms'),
})
},
}
}
private wrapPluginForEsbuild(plugin: Plugin): esbuild.Plugin {
const pluginsExecutor: PluginsExecutor = this
const ctx = this.ctx
const executor = this
return {
name: plugin.name,
setup({ onLoad, onResolve }) {
// TODO running setup 2 times
plugin.setup({
onResolve,
// the plugin transform is already inside pluginsExecutor
onTransform() {},
onClose() {},
ctx,
pluginsExecutor,
initialOptions: executor.startingInitialOptions,
// wrap onLoad to execute other plugins transforms
onLoad(options, callback) {
onLoad(options, async (args) => {
const result = await callback(args)
if (!result) {
return
}
// run all transforms from other plugins
const transformed = await pluginsExecutor.transform(
{
path: args.path,
contents: String(result?.contents),
loader: result.loader || 'default',
},
)
if (!transformed) {
return result
}
return {
...result,
contents: transformed.contents,
loader: transformed.loader || result.loader,
resolveDir: result.resolveDir,
}
})
},
})
},
}
}
}
const sum = (a, b): number => a + b
export function sortPlugins(plugins?: Plugin[]): [Plugin[], Plugin[]] {
if (!plugins) {
return [[], []]
}
const [pre, post]: Plugin[][] = [[], []]
for (let plugin of plugins) {
if (plugin.enforce === 'pre') {
pre.push(plugin)
} else if (plugin.enforce === 'post') {
post.push(plugin)
} else {
pre.push(plugin)
}
}
return [pre, post]
}
================================================
FILE: bundless/src/prebundle/__snapshots__/prebundle.test.ts.snap
================================================
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`traverseWithEsbuild 1`] = `
Array [
"index.html",
"main.js",
"../../node_modules/slash/index.js",
"../../node_modules/react/index.js",
"node_modules/preact/hooks/dist/hooks.module.js",
"../../node_modules/react-dom/index.js",
]
`;
================================================
FILE: bundless/src/prebundle/esbuild.ts
================================================
import * as esbuild from 'esbuild'
import { Metafile } from 'esbuild'
import fromEntries from 'fromentries'
import fs from 'fs-extra'
import path from 'path'
import slash from 'slash'
import tmpfile from 'tmpfile'
import { Config, Platform } from '../config'
import { osAgnosticPath } from '../utils'
import * as plugins from '../plugins'
import {
defaultImportableAssets as defaultImportableAssets,
defaultLoader,
isRunningWithYarnPnp,
JS_EXTENSIONS,
MAIN_FIELDS,
} from '../constants'
import { logger } from '../logger'
import { DependencyStatsOutput } from './stats'
import {
OptimizeAnalysisResult,
runFunctionOnPaths,
stripColon,
} from './support'
import { PluginsExecutor } from '../plugins-executor'
export const commonEsbuildOptions = (
config: Config = {},
): esbuild.BuildOptions => {
const omitHashes = process.env.BUNDLESS_CONSISTENT_HMR_GRAPH_HASH != null
return {
target: 'es2020',
entryNames: !omitHashes ? '[dir]/[name]-[hash]' : '[dir]/[name]',
chunkNames: 'chunks/[name]-[hash]',
minify: false,
minifyIdentifiers: false,
minifySyntax: false,
metafile: true,
minifyWhitespace: false,
mainFields: MAIN_FIELDS,
sourcemap: false,
bundle: true,
platform: 'browser',
format: 'esm',
write: true,
logLevel: 'error',
loader: {
'.js': 'jsx',
'.cjs': 'js',
// '.svg': 'dataurl', // TODO enable svg as data uri in development and in build
...defaultLoader,
...config.loader,
},
define: generateDefineObject({ config }),
}
}
export function generateDefineObject({
config = {} as Config,
platform = 'browser' as Platform,
isProd = false,
}) {
if (platform === 'node') {
return {
'process.browser': 'false',
...config.define, // TODO mock browser stuff like fetch? this allows me to target other platform like cloudflare workers ...
}
}
const noop = 'String'
const nodeEnv =
process.env.NODE_ENV || (isProd ? 'production' : 'development')
return {
'process.env.NODE_ENV': JSON.stringify(nodeEnv),
// ...generateEnvReplacements(config.env || {}),
'process.pid': '0',
// global: 'window',
__filename: '""',
__dirname: '""',
// TODO remove defines and use inject instead
// TODO use the process inject instead of define
// process: '{}',
global: 'window',
// 'process.env': '{}',
'process.browser': 'true',
'process.version': '""',
// 'process.argv': '[]',
// module: '{}',
// Buffer: noop,
// 'process.cwd': noop,
// 'process.chdir': noop,
clearImmediate: noop,
setImmediate: noop,
...config.define,
}
}
export const defaultResolvableExtensions = [
...JS_EXTENSIONS,
...defaultImportableAssets,
'.json',
'.css',
]
export async function bundleWithEsBuild({
entryPoints,
root,
dest: destLoc,
config,
...options
}) {
const { alias = {}, externalPackages = [], minify = false } = options
const tsconfigTempFile = tmpfile('.json')
await fs.promises.writeFile(tsconfigTempFile, makeTsConfig({ alias }))
// rimraf.sync(destLoc) // do not delete or on flight imports will return 404
const initialOptions: esbuild.BuildOptions = {
entryPoints,
...commonEsbuildOptions(config),
splitting: true, // needed to dedupe modules
external: externalPackages,
minify: Boolean(minify),
minifyIdentifiers: Boolean(minify),
minifySyntax: Boolean(minify),
minifyWhitespace: Boolean(minify),
mainFields: MAIN_FIELDS,
tsconfig: tsconfigTempFile,
sourcemap: 'inline',
bundle: true,
write: true,
outdir: destLoc,
metafile: true,
}
const executor = new PluginsExecutor({
initialOptions,
ctx: {
config: { root },
isBuild: true,
root,
},
plugins: [
...(config.plugins || []),
plugins.NodeGlobalsPolyfillPlugin({
buffer: true,
process: true,
define: initialOptions.define,
}),
plugins.NodeModulesPolyfillPlugin({
namespace: 'node-modules-polyfills',
}),
plugins.CssPlugin(),
plugins.NodeResolvePlugin({
name: 'prebundle-node-resolve',
mainFields: MAIN_FIELDS,
extensions: [
...defaultResolvableExtensions,
...(Object.keys(config.loader || {}) || []),
],
onNonResolved: (p, importer, e) => {
logger.debug(e.message + '\n' + e.stack)
// logger.warn(
// `Cannot resolve '${p}' from '${importer}' during traversal, using yarn pnp: ${isRunningWithYarnPnp}`,
// )
},
}),
plugins.UrlResolverPlugin(),
],
})
const buildResult = await esbuild.build({
...initialOptions,
plugins: executor.esbuildPlugins(),
})
await fs.promises.unlink(tsconfigTempFile)
let meta = buildResult.metafile!
meta = runFunctionOnPaths(meta, (p) => {
p = stripColon(p) // namespace:/path/to/file -> /path/to/file
return p
})
const esbuildCwd = process.cwd()
const bundleMap = metafileToBundleMap({
meta,
esbuildCwd,
root,
})
const analysis = metafileToAnalysis({ meta, root, esbuildCwd })
const stats = metafileToStats({ meta, destLoc })
return { stats, bundleMap, analysis }
}
function makeTsConfig({ alias }) {
const aliases = Object.keys(alias || {}).map((k) => {
return {
[k]: [alias[k]],
}
})
const tsconfig = {
compilerOptions: { baseUrl: '.', paths: Object.assign({}, ...aliases) },
}
return JSON.stringify(tsconfig)
}
export type BundleMap = Partial>
/**
* Returns aon object that maps from entry (relative path from root) to output (relative path from root too)
*/
export function metafileToBundleMap(_options: {
root: string
esbuildCwd: string
meta: Metafile
}): BundleMap {
const { meta, root, esbuildCwd } = _options
const maps: Array<[string, string]> = Object.keys(meta.outputs)
.map((output): [string, string] | undefined => {
// chunks cannot be entrypoints
const entry = meta.outputs[output].entryPoint
if (!entry) {
return
}
return [
osAgnosticPath(path.resolve(esbuildCwd, entry), root),
osAgnosticPath(path.resolve(esbuildCwd, output), root),
]
})
.filter(Boolean) as any
const bundleMap = fromEntries(maps)
return bundleMap
}
function metafileToAnalysis(_options: {
meta: Metafile
root: string
esbuildCwd: string
}): OptimizeAnalysisResult {
const { meta, root, esbuildCwd } = _options
const analysis: OptimizeAnalysisResult = {
isCommonjs: fromEntries(
Object.keys(meta.outputs)
.map((output): [string, true] | undefined => {
if (path.basename(output).startsWith('chunk.')) {
return
}
const info = meta.outputs[output]
if (!info) {
throw new Error(`cannot find output info for ${output}`)
}
const isCommonjs =
info.exports?.length === 1 &&
info.exports?.[0] === 'default'
if (!isCommonjs) {
return
}
// what if imported path ahs not yet been converted by prebundler? then prebundler should lock server, it's impossible
return [
osAgnosticPath(path.resolve(esbuildCwd, output), root),
isCommonjs,
]
})
.filter(Boolean) as any,
),
}
return analysis
}
export function metafileToStats(_options: {
meta: Metafile
destLoc: string
}): DependencyStatsOutput {
const { meta, destLoc } = _options
const stats = Object.keys(meta.outputs).map((output) => {
const value = meta.outputs[output]
// const inputs = meta.outputs[output].bytes;
return {
path: output,
isCommon: ['chunk.'].some((x) =>
path.basename(output).startsWith(x),
),
bytes: value.bytes,
}
})
function makeStatObject(value) {
const relativePath = slash(path.relative(destLoc, value.path))
return {
[relativePath]: {
size: value.bytes,
// gzip: zlib.gzipSync(contents).byteLength,
// brotli: zlib.brotliCompressSync ? zlib.brotliCompressSync(contents).byteLength : 0,
},
}
}
return {
common: Object.assign(
{},
...stats.filter((x) => x.isCommon).map(makeStatObject),
),
direct: Object.assign(
{},
...stats.filter((x) => !x.isCommon).map(makeStatObject),
),
}
}
================================================
FILE: bundless/src/prebundle/index.ts
================================================
export { prebundle } from './prebundle'
================================================
FILE: bundless/src/prebundle/prebundle.test.ts
================================================
import memoize from 'micro-memoize'
import path from 'path'
import { makeEntryObject } from './prebundle'
import { traverseWithEsbuild } from './traverse'
test('traverseWithEsbuild', async () => {
const entry = path.resolve('fixtures/with-many-dependencies/index.html')
const deps = await traverseWithEsbuild({
entryPoints: [entry],
// esbuildCwd: process.cwd(),
config: {},
root: path.dirname(entry),
})
expect(deps).toMatchSnapshot()
})
test('memoize', () => {
let i = 0
const fn = memoize((x) => {
return i++
})
fn(1)
fn(1)
fn.cache.keys = []
fn.cache.values = []
fn(1)
fn(1)
fn(1)
expect(i).toBe(2)
})
test('makeEntryObject', () => {
const deps = ['xxx', 'xxx', 'xxx', 'yyy', 'aaa']
const obj = makeEntryObject(deps)
console.log(obj)
expect(Object.keys(obj).length).toBe(deps.length)
})
================================================
FILE: bundless/src/prebundle/prebundle.ts
================================================
import fs from 'fs-extra'
import path from 'path'
import chalk from 'chalk'
import {
BUNDLE_MAP_PATH,
COMMONJS_ANALYSIS_PATH,
pnpapi,
WEB_MODULES_PATH,
} from '../constants'
import { logger } from '../logger'
import { clearCommonjsAnalysisCache } from '../plugins/rewrite/commonjs'
import { bundleWithEsBuild, generateDefineObject } from './esbuild'
import { printStats } from './stats'
import { isEmpty, needsPrebundle, osAgnosticPath } from '../utils'
import { traverseWithEsbuild } from './traverse'
export async function prebundle({ entryPoints, config, root, dest }) {
try {
logger.spinStart(`Prebundling modules in '${WEB_MODULES_PATH}'`)
const traversalResult = await traverseWithEsbuild({
entryPoints,
root,
config,
filter: /^[\w@][^:]/, // bare name imports (no relative imports)
})
logger.debug(`traversed files`)
const dependenciesPaths = traversalResult.filter((p) =>
needsPrebundle(config, p),
)
await fs.remove(dest)
if (!dependenciesPaths.length) {
logger.log(`No dependencies to prebundle found`)
return {}
}
logger.log(
`Prebundling \n ${dependenciesPaths
.map((x) => getClearDependencyPath(x))
.map((x) => (path.isAbsolute(x) ? osAgnosticPath(x, root) : x))
.map((x) => chalk.cyanBright(x))
.join('\n ')}\n`,
)
// TODO separate build for workspaces and dependencies, build workspaces in watch mode, also pass user plugins
// TODO do not stop traversal on workspaces, grab all dependencies including inside workspaces (to node duplicate deps)
// TODO build workspaces in separate build step, make external dependencies using the needsPrebundle logic
let { bundleMap, analysis, stats } = await bundleWithEsBuild({
dest,
root,
config,
entryPoints: makeEntryObject(
dependenciesPaths.map((x) => path.resolve(root, x)),
),
})
logger.spinSucceed('\nFinish')
const analysisFile = path.resolve(root, COMMONJS_ANALYSIS_PATH)
await fs.createFile(analysisFile)
await fs.writeFile(analysisFile, JSON.stringify(analysis, null, 4))
console.info(
printStats({ dependencyStats: stats, destLoc: WEB_MODULES_PATH }),
)
if (!isEmpty(bundleMap)) {
const bundleMapCachePath = path.resolve(root, BUNDLE_MAP_PATH)
await fs.writeJSON(bundleMapCachePath, bundleMap, { spaces: 4 })
}
return bundleMap
} catch (e) {
logger.spinFail('Cannot prebundle\n')
throw e
} finally {
clearCommonjsAnalysisCache()
}
}
function getClearDependencyPath(p: string) {
const index = p.lastIndexOf('node_modules')
if (index === -1) {
return p
}
let dependencySubPath = p.slice(index).replace(/\/?node_modules(\/|\\)/, '')
return dependencySubPath
}
function getScopedPackageName(path: string): any {
return path.match(/(@[\w-_\.]+\/[\w-_\.]+)/)?.[1] || ''
}
function getPackageName(p: string) {
const dependencySubPath = getClearDependencyPath(p)
let dependency = ''
if (dependencySubPath.startsWith('@')) {
dependency = getScopedPackageName(dependencySubPath) || ''
} else {
const lastIndex = dependencySubPath.indexOf('/')
dependency = dependencySubPath.slice(
0,
lastIndex === -1 ? undefined : lastIndex,
)
}
return dependency
}
export function makeEntryObject(dependenciesPaths: string[]) {
const names: Record = {}
return Object.assign(
{},
...dependenciesPaths.map((f) => {
let outputPath = getClearDependencyPath(f) || 'unknown'
const sameNamesCount = names[outputPath]
if (sameNamesCount) {
names[outputPath] += 1
outputPath += String(sameNamesCount)
} else {
names[outputPath] = 1
}
return {
[outputPath]: f,
}
}),
)
}
================================================
FILE: bundless/src/prebundle/stats.ts
================================================
import chalk from 'chalk'
export type DependencyType = 'direct' | 'common'
export type DependencyStatsMap = {
[filePath: string]: DependencyStats
}
type DependencyStats = { size: number }
export type DependencyStatsOutput = Record
export function printStats(_args: {
dependencyStats: DependencyStatsOutput
destLoc: string
}): string {
const { dependencyStats, destLoc } = _args
let output = ''
const { direct, common } = dependencyStats
const allDirect = Object.entries(direct).sort(entriesSort)
const allCommon = Object.entries(common).sort(entriesSort)
const maxFileNameLength =
[...allCommon, ...allDirect].reduce(
(max, [filename]) => Math.max(filename.length, max),
destLoc.length,
) + 1
output +=
` ⦿ ${chalk.bold(destLoc.padEnd(maxFileNameLength + 4))}` +
chalk.bold(chalk.underline('size'.padEnd(SIZE_COLUMN_WIDTH - 2))) +
' ' +
// chalk.bold(chalk.underline('gzip'.padEnd(SIZE_COLUMN_WIDTH - 2))) +
// ' ' +
// chalk.bold(chalk.underline('brotli'.padEnd(SIZE_COLUMN_WIDTH - 2))) +
`\n`
output += `${formatFiles(allDirect, maxFileNameLength)}\n`
if (Object.values(common).length > 0) {
output += ` ⦿ ${chalk.bold('chunks (Shared)')}\n`
output += `${formatFiles(allCommon, maxFileNameLength)}`
}
return `\n${output}\n`
}
/** The minimum width, in characters, of each size column */
const SIZE_COLUMN_WIDTH = 11
/** Generic Object.entries() alphabetical sort by keys. */
function entriesSort([filenameA]: [string, any], [filenameB]: [string, any]) {
return filenameA.localeCompare(filenameB)
}
/** Pretty-prints number of bytes as "XXX KB" */
function formatSize(size) {
let kb = Math.round((size / 1000) * 100) / 100
if (kb >= 1000) {
kb = Math.floor(kb)
}
let color
if (kb < 15) {
color = 'green'
} else if (kb < 30) {
color = 'yellow'
} else {
color = 'red'
}
return chalk[color](`${kb} KB`.padEnd(SIZE_COLUMN_WIDTH))
}
function formatDelta(delta) {
const kb = Math.round(delta * 100) / 100
const color = delta > 0 ? 'red' : 'green'
return chalk[color](`Δ ${delta > 0 ? '+' : ''}${kb} KB`)
}
function formatFileInfo(
filename: string,
stats: DependencyStats,
padEnd: number,
isLastFile: boolean,
): string {
const lineGlyph = chalk.dim(isLastFile ? '└─' : '├─')
const lineName = filename.padEnd(padEnd)
const fileStat = formatSize(stats.size)
// const gzipStat = formatSize(stats.gzip)
// const brotliStat = formatSize(stats.brotli)
const lineStat = fileStat // + gzipStat + brotliStat
let lineDelta = ''
// if (stats.delta) {
// lineDelta = chalk.dim('[') + formatDelta(stats.delta) + chalk.dim(']')
// }
// Trim trailing whitespace (can mess with formatting), but keep indentation.
return ` ` + `${lineGlyph} ${lineName} ${lineStat} ${lineDelta}`.trim()
}
function formatFiles(files: [string, DependencyStats][], padEnd: number) {
const strippedFiles = files.map(([filename, stats]) => [
filename.replace(/^common\//, ''),
stats,
]) as [string, DependencyStats][]
return strippedFiles
.map(([filename, stats], index) =>
formatFileInfo(filename, stats, padEnd, index >= files.length - 1),
)
.join('\n')
}
================================================
FILE: bundless/src/prebundle/support.ts
================================================
import { Metafile } from 'esbuild'
import { forOwn, isPlainObject } from 'lodash'
export function isUrl(req: string) {
return (
req.startsWith('http://') ||
req.startsWith('https://') ||
req.startsWith('//')
)
}
export interface OptimizeAnalysisResult {
isCommonjs: { [name: string]: true }
}
export function unique(array: T[], key = (x: T): any => x): T[] {
const cache: Record = {}
return array.filter(function (a) {
const keyed = key(a)
if (!cache[keyed]) {
cache[keyed] = true
return true
}
return false
}, {})
}
// namespace:/path/to/file -> /path/to/file
export function stripColon(input?: string) {
if (!input) {
return ''
}
const index = input.indexOf(':')
if (index === -1) {
return input
}
const clean = input.slice(index + 1)
return clean
}
function convertKeys(obj: T, cb: (k: string) => string): T {
const x: T = Array.isArray(obj) ? ([] as any) : {}
forOwn(obj, (v, k) => {
if (isPlainObject(v) || Array.isArray(v)) v = convertKeys(v, cb)
x[cb(k)] = v
})
return x
}
export function runFunctionOnPaths(
x: Metafile,
func: (x: string) => string = stripColon,
): Metafile {
x = convertKeys(x, func)
for (const input in x.inputs) {
const v = x.inputs[input]
x.inputs[input] = {
...v,
imports: v.imports
? v.imports.map((x) => ({ ...x, path: func(x.path) }))
: [],
}
}
for (const output in x.outputs) {
const v = x.outputs[output]
x.outputs[output] = {
...v,
imports: v.imports
? v.imports.map((x) => ({ ...x, path: func(x.path) }))
: [],
}
}
return x
}
================================================
FILE: bundless/src/prebundle/traverse.ts
================================================
import deepmerge from 'deepmerge'
import * as esbuild from 'esbuild'
import { build, BuildOptions, Metafile, Plugin } from 'esbuild'
import fromEntries from 'fromentries'
import { promises as fsp } from 'fs'
import { resolveAsync } from '@esbuild-plugins/all'
import fsx from 'fs-extra'
import os from 'os'
import path from 'path'
import { isRunningWithYarnPnp, MAIN_FIELDS } from '../constants'
import { HmrGraph } from '../hmr-graph'
import { logger } from '../logger'
import { PluginsExecutor } from '../plugins-executor'
import * as plugins from '../plugins'
import { flatten, needsPrebundle, osAgnosticPath } from '../utils'
import {
commonEsbuildOptions,
generateDefineObject,
defaultResolvableExtensions,
} from './esbuild'
import { runFunctionOnPaths, stripColon, unique } from './support'
import { rewriteScriptUrlsTransform } from '../serve'
import { Config } from '../config'
type Args = {
root: string
entryPoints: string[]
config: Config
filter?: RegExp
esbuildOptions?: Partial
// resolver?: (cwd: string, id: string) => string
stopTraversing?: (resolvedPath: string) => boolean
}
export async function traverseWithEsbuild({
entryPoints,
filter,
root,
config,
}: Args): Promise {
const userPlugins = config.plugins || []
const destLoc = await fsp.realpath(
path.resolve(await fsp.mkdtemp(path.join(os.tmpdir(), 'dest'))),
)
for (let entry of entryPoints) {
if (!path.isAbsolute(entry)) {
throw new Error(
`All entryPoints of traverseWithEsbuild must be absolute: ${entry}`,
)
}
}
logger.debug(`Traversing entrypoints ${JSON.stringify(entryPoints, [], 4)}`)
const allPlugins = [
// TODO esbuild does not let overriding plugins, this means that if user is using plugin to alias a package to a file it will skip ExternalButInMetafile and break everything
...(userPlugins || []),
plugins.NodeModulesPolyfillPlugin(),
plugins.HtmlResolverPlugin(),
plugins.HtmlTransformUrlsPlugin({
transforms: [rewriteScriptUrlsTransform],
}),
plugins.HtmlIngestPlugin({ root }),
plugins.NodeResolvePlugin({
name: 'traverse-node-resolve',
mainFields: MAIN_FIELDS,
extensions: [
...defaultResolvableExtensions,
...(Object.keys(config.loader || {}) || []),
],
// TODO use different plugin that only runs on bare imports
onNonResolved: (p, importer, e) => {
logger.debug(e.message + '\n' + e.stack)
// logger.warn(
// `Cannot resolve '${p}' from '${importer}' during traversal, using yarn pnp: ${isRunningWithYarnPnp}`,
// )
},
}),
plugins.UrlResolverPlugin(),
]
const initialOptions: esbuild.BuildOptions = {
...commonEsbuildOptions(config),
entryPoints,
outdir: destLoc,
}
const pluginsExecutor = new PluginsExecutor({
plugins: allPlugins,
initialOptions,
ctx: {
isBuild: true,
config: { root },
root,
},
})
let graph: TraversalGraph = {}
try {
await build({
...initialOptions,
plugins: [
traversalGraphPlugin({
executor: pluginsExecutor,
graph,
filter,
stopTraversing(p) {
return needsPrebundle(config, p)
},
}),
...pluginsExecutor.esbuildPlugins(),
],
})
// console.log(JSON.stringify(meta, null, 4))
let knownModules = pluginsExecutor.modulesToPrebundle()
knownModules = await Promise.all(
knownModules.map((x) =>
resolveAsync(x, {
basedir: root,
mainFields: MAIN_FIELDS,
}).then((x) => x || ''),
),
)
knownModules = knownModules.filter(Boolean)
return unique([...Object.keys(graph), ...knownModules])
} finally {
await fsx.remove(destLoc)
}
}
export function traversalGraphPlugin({
filter,
graph,
executor,
stopTraversing,
}: {
filter?: RegExp
graph: TraversalGraph
executor: PluginsExecutor
stopTraversing: Function
}): esbuild.Plugin {
return {
name: 'register-modules',
setup({ onResolve }) {
onResolve({ filter: filter || /()/ }, async (args) => {
const res = await executor.resolve({
importer: args.importer,
path: args.path,
namespace: 'file',
resolveDir: args.importer
? path.dirname(args.importer)
: args.resolveDir,
skipOnResolved: true,
})
if (!res || !res.path) {
return res
}
const importer = osAgnosticPath(
args.importer,
executor.ctx.root,
)
const importee = osAgnosticPath(res.path, executor.ctx.root)
if (importer) {
if (!graph[importer]) {
graph[importer] = [importee]
} else {
graph[importer].push(importee)
}
}
if (!graph[importee]) {
graph[importee] = []
}
if (stopTraversing(res.path)) {
logger.debug(
`Stopping traversing at ${res.path}, ${args.path}`,
)
return { external: true }
}
})
},
}
}
type TraversalGraph = Record
/**
* Returns a module graph implemented as an object, keys are modules (relative paths from root), values are arrays of key's imports (absolute paths)
*/
export function metaToTraversalResult({
meta,
entryPoints,
esbuildCwd,
root,
}: {
meta: Metafile
esbuildCwd: string
root: string
entryPoints: string[]
}): TraversalGraph {
if (!path.isAbsolute(esbuildCwd)) {
throw new Error('esbuildCwd must be an absolute path')
}
for (let entry of entryPoints) {
if (!path.isAbsolute(entry)) {
throw new Error('entry must be an absolute path')
}
}
const alreadyProcessed = new Set()
// must be all absolute paths
let toProcess = entryPoints
const result: TraversalGraph = {}
// abs path -> input info
const inputs: Record = fromEntries(
Object.keys(meta.inputs).map((k) => {
const abs = path.resolve(esbuildCwd, k)
return [abs, meta.inputs[k]]
}),
)
while (toProcess.length) {
const newImports = flatten(
toProcess.map((absPath): string[] => {
if (alreadyProcessed.has(absPath)) {
return []
}
alreadyProcessed.add(absPath)
// newEntry = path.posix.normalize(newEntry) // TODO does esbuild always use posix?
const input = inputs[absPath]
if (input == null) {
throw new Error(
`entry '${absPath}' is not present in esbuild metafile inputs ${JSON.stringify(
Object.keys(inputs),
null,
2,
)}`,
)
}
// abs paths
const currentImports: string[] = input.imports
? input.imports
.map((x) => x.path)
.map((x) => {
if (!path.isAbsolute(x)) {
return path.resolve(esbuildCwd, x)
}
return x
})
.filter((x) => Boolean(x))
: []
// newImports.push(...currentImports)
const importer = osAgnosticPath(
path.resolve(esbuildCwd, absPath),
root,
)
if (!result[importer]) {
result[importer] = []
}
for (let importee of currentImports) {
if (!importee) {
continue
}
importee = osAgnosticPath(importee, root)
result[importer].push(importee)
}
return currentImports
}),
).filter(Boolean)
toProcess = newImports
}
return result
// find the right output getting the key of the right output.inputs == input
// get the imports of the inputs.[entry].imports and attach them the importer
// do the same with the imports just found
// return the list of input files
}
================================================
FILE: bundless/src/serve.ts
================================================
import chalk from 'chalk'
import chokidar, { FSWatcher } from 'chokidar'
import { createHash } from 'crypto'
import * as esbuild from 'esbuild'
import findUp from 'find-up'
import fs from 'fs-extra'
import { getPort } from 'get-port-please'
import { Server } from 'http'
import Koa, { DefaultContext, DefaultState } from 'koa'
import etagMiddleware from 'koa-etag'
import net from 'net'
import path from 'path'
import { Node } from 'posthtml'
import slash from 'slash'
import { promisify } from 'util'
import { HMRPayload } from './client/types'
import { Config, defaultConfig, getEntries, normalizeConfig } from './config'
import {
BUNDLE_MAP_PATH,
DEFAULT_PORT,
defaultImportableAssets,
JS_EXTENSIONS,
MAIN_FIELDS,
showGraph,
WEB_MODULES_PATH,
pnpapi,
} from './constants'
import { HmrGraph } from './hmr-graph'
import { logger } from './logger'
import * as middlewares from './middleware'
import * as plugins from './plugins'
import {
OnResolved,
PluginsExecutor,
PluginsExecutorCtx,
sortPlugins,
} from './plugins-executor'
import { prebundle } from './prebundle'
import { BundleMap, generateDefineObject } from './prebundle/esbuild'
import { isUrl } from './prebundle/support'
import {
appendQuery,
isEmpty,
Lock,
needsPrebundle,
osAgnosticPath,
parseWithQuery,
prepareError,
} from './utils'
process.env.NODE_ENV = process.env.NODE_ENV || 'development'
export interface ServerPluginContext {
root: string
app: Koa
graph: HmrGraph
pluginExecutor: PluginsExecutor
// server: Server
watcher: FSWatcher
server?: Server
config: Config
sendHmrMessage: (payload: HMRPayload) => void
port: number
}
export type ServerMiddleware = (ctx: ServerPluginContext) => void
export async function serve(config: Config) {
config = normalizeConfig(config)
let server = new Server()
const { app } = await createDevApp(server, config)
server.on('request', app.callback())
const preferredServerPort = config.server?.port || DEFAULT_PORT
const port = await getPort(preferredServerPort)
if (Number(preferredServerPort) !== Number(port)) {
logger.warn(
`Using port ${port} because ${preferredServerPort} is already in use`,
)
}
await promisify(server.listen.bind(server) as any)(port)
process.stdout.write('\n')
logger.log(
`Listening on ${chalk.cyan.underline(`http://localhost:${port}`)}`,
)
return server
}
export const onResolveLock = new Lock()
export async function createDevApp(server: net.Server, config: Config) {
config = normalizeConfig(config)
if (!config.root) {
config.root = process.cwd()
}
const { root } = config
const app = new Koa()
const graph = new HmrGraph({ root, server })
const watcher = chokidar.watch(root, {
ignored: ['**/node_modules/**', '**/.git/**', '**/.bundless'],
useFsEvents: shouldUseFsEvents(),
ignoreInitial: true,
// ...chokidarWatchOptions
})
const executorCtx: PluginsExecutorCtx = {
config,
isBuild: false,
graph,
root,
watcher,
}
// when resolving if we encounter a node_module run the prebundling phase and invalidate some caches
const onResolved: OnResolved = async function onResolved(arg) {
const { path: resolvedPath, importer } = arg
if (!resolvedPath) {
return
}
try {
// lock browser requests until not prebundled
await onResolveLock.wait()
if (!needsPrebundle(config, resolvedPath)) {
return
}
let relativePath = osAgnosticPath(resolvedPath, root)
if (bundleMap && bundleMap[relativePath]) {
const webBundle = bundleMap[relativePath]
return { ...arg, path: path.resolve(root, webBundle!) }
}
onResolveLock.lock()
// TODO do not rerun prebundle if file extension is an asset like css?
logger.log(
`Found still not bundled module '${relativePath}' imported by '${importer}', running prebundle phase:`,
)
logger.debug(resolvedPath)
graph.sendHmrMessage({
type: 'overlay-info-open',
info: {
message: `Prebundling dependencies, please wait`,
showSpinner: true,
},
})
// node module path not bundled, rerun bundling
const entryPoints = await getEntries(pluginsExecutor, config)
logger.debug(`got entries`)
// TODO make prebundled files cachable indefinitley given they are named with an hash
bundleMap = await prebundle({
entryPoints,
dest: path.resolve(root, WEB_MODULES_PATH),
config,
root,
}).catch((e) => {
graph.sendHmrMessage({
type: 'overlay-info-close',
})
graph.sendHmrMessage({
type: 'overlay-error',
err: prepareError(e),
})
throw e
})
graph.sendHmrMessage({
type: 'overlay-info-close',
})
await updateHash(hashPath, depsHash)
graph.sendHmrMessage({ type: 'reload' })
const webBundle = bundleMap[relativePath]
if (!webBundle) {
throw new Error(
`Bundle for '${relativePath}' was not generated in prebundling phase`,
)
}
return { ...arg, path: path.resolve(root, webBundle) }
} catch (e) {
throw e
} finally {
onResolveLock.ready()
}
}
const [prePlugins, postPlugins] = sortPlugins(config.plugins)
const initialOptions: esbuild.BuildOptions = {
loader: config.loader,
bundle: false,
minify: false,
define: config.define,
} // TODO better esbuild initialOptions for serve
// most of the logic is in plugins
const pluginsExecutor = new PluginsExecutor({
ctx: executorCtx,
isProfiling: config.printStats,
initialOptions,
onResolved,
plugins: [
...prePlugins,
// TODO resolve `data:` imports, rollup emits imports with data: ...
plugins.HtmlResolverPlugin(),
plugins.UrlResolverPlugin(), // resolves urls with queries
plugins.HmrClientPlugin({
getPort: () => server.address()?.['port'],
}),
plugins.CssPlugin(),
// NodeResolvePlugin must be called first, to not skip prebundling
plugins.NodeResolvePlugin({
name: 'node-resolve',
mainFields: MAIN_FIELDS,
extensions: [...JS_EXTENSIONS],
}),
plugins.AssetsPlugin({
loader: config.loader,
}),
plugins.NodeModulesPolyfillPlugin({ namespace: 'node-builtins' }),
plugins.EsbuildTransformPlugin(),
plugins.JSONPlugin(),
plugins.ResolveSourcemapPlugin(),
plugins.HtmlTransformUrlsPlugin({
// must come before rewrite to not warn about the client script not having type=module
transforms: [rewriteScriptUrlsTransform],
}),
plugins.SourceMapSupportPlugin(), // adds source map to errors traces, must be after hmr client plugin
...postPlugins,
plugins.RewritePlugin(),
],
})
const bundleMapCachePath = path.resolve(root, BUNDLE_MAP_PATH)
const hashPath = path.resolve(root, WEB_MODULES_PATH, 'deps_hash')
const depsHash = await getDepsHash(root)
let prevHash = await fs
.readFile(hashPath)
.catch(() => '')
.then((x) => x.toString().trim())
const isHashDifferent = !depsHash || !prevHash || prevHash !== depsHash
if (config.prebundle?.force || isHashDifferent) {
if (isHashDifferent) {
logger.log(`Dependencies changed, running prebundle phase`)
logger.debug('isHashDifferent', isHashDifferent, prevHash, depsHash)
}
await fs.remove(path.resolve(root, '.bundless'))
}
let bundleMap: BundleMap = await fs
.readJSON(bundleMapCachePath)
.catch(() => {
return {}
})
if (isEmpty(bundleMap)) {
bundleMap = await prebundle({
entryPoints: await getEntries(pluginsExecutor, config),
config,
dest: path.resolve(root, WEB_MODULES_PATH),
root,
})
await updateHash(hashPath, depsHash)
}
server.once('close', async () => {
logger.debug('closing')
await Promise.all([watcher.close(), pluginsExecutor.close()])
app.emit('closed')
})
if (config.printStats) {
process.on('SIGINT', () => {
process.stdout.write('\n')
console.info(pluginsExecutor.printProfilingResult())
process.exit(0)
})
}
app.on('error', (e: Error) => {
console.error(chalk.red(e.message))
console.error(chalk.red(e.stack))
graph.sendHmrMessage({ type: 'overlay-error', err: prepareError(e) })
})
server.once('listening', () => {
config.server = { ...config.server, port: server.address()?.['port'] }
})
if (config.server?.hmr) {
watcher.on('change', (filePath) => {
graph.onFileChange({
filePath,
})
if (showGraph) {
logger.log(graph.toString())
}
})
}
// only js ends up here
app.use(middlewares.openInEditorMiddleware({ root }))
app.use(middlewares.sourcemapMiddleware({ root }))
app.use(middlewares.pluginsMiddleware({ root, pluginsExecutor, watcher }))
app.use(middlewares.historyFallbackMiddleware({ root, pluginsExecutor }))
app.use(middlewares.staticServeMiddleware({ root }))
app.use(
middlewares.staticServeMiddleware({
root: path.resolve(root, 'public'),
}),
)
// app.use(etagMiddleware())
// cors
if (config.server?.cors) {
app.use(
require('@koa/cors')(
typeof config.server?.cors === 'boolean'
? {}
: config.server?.cors,
),
)
}
return { app, pluginsExecutor }
}
// hash assumes that import paths can only grow when installed dependencies grow, this is not the case for deep paths like `lodash/path`, in these cases you will need to use `--force`
// TODO include config in hash
async function getDepsHash(root: string) {
const lockfileLoc = await findUp(
['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'],
{
cwd: root,
},
)
if (!lockfileLoc) {
return ''
}
const content = await (await fs.readFile(lockfileLoc, 'utf-8')).toString()
return createHash('sha1').update(content).digest('base64').trim()
}
async function updateHash(hashPath: string, newHash: string) {
await fs.createFile(hashPath)
await fs.writeFile(hashPath, newHash.trim())
}
export const rewriteScriptUrlsTransform = (tree: Node) => {
let count = 0
tree.walk((node) => {
if (
node &&
node.tag === 'script' &&
node.attrs &&
node.attrs['src'] &&
!isUrl(node.attrs['src'])
) {
count += 1
let importPath = node.attrs['src']
if (node.attrs['type'] !== 'module') {
logger.warn(
`