diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..0686197 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,4 @@ +{ + "root": true, + "extends": ["@lessstack/eslint-config"] +} diff --git a/.gitignore b/.gitignore index 99600f9..3f1535b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies node_modules -yarn-error.log \ No newline at end of file +.pnp +.pnp.js + +# testing +coverage + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo + +# user space notes, ideas,… +.user \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..7241764 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no-install commitlint --edit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..75fac8e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run lint diff --git a/.huskyrc b/.huskyrc new file mode 100644 index 0000000..e939469 --- /dev/null +++ b/.huskyrc @@ -0,0 +1,3 @@ +# This loads nvm.sh and sets the correct PATH before running hook +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..154647c --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +public-hoist-pattern[]=webpack +public-hoist-pattern[]=*eslint* \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..6b8410b --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +"@lessstack/prettier-config" diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1d7ac85 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..274bd39 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,46 @@ +{ + "[javascript]": { + "editor.codeActionsOnSave": { + "source.fixAll": true + }, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescript]": { + "editor.codeActionsOnSave": { + "source.fixAll": true + }, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescriptreact]": { + "editor.codeActionsOnSave": { + "source.fixAll": true + }, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[json]": { + "editor.codeActionsOnSave": { + "source.fixAll": true + }, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.codeActionsOnSave": { + "source.fixAll": true + }, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "cSpell.words": [ + "cahnory", + "camelcase", + "commitlint", + "gifsicle", + "hydratation", + "jpegtran", + "lessstack", + "no-plusplus", + "optipng", + "Pipeable", + "pmmmwh", + "proptype" + ] +} diff --git a/README.md b/README.md deleted file mode 100644 index cd04016..0000000 --- a/README.md +++ /dev/null @@ -1,13 +0,0 @@ -## TODO - -- ~~find a real name to replace @hmr brand and \_\_HMR\_\_ webpack defined variable~~ -- search a way to use unpublished in workspace eslint-config without resolving it -- try again to make webpack-fontface works with linaria -- improve clusterizer logging -- set prepush hook using husky -- ~~make node client renderer logger configurable (param of pipeToResponse)~~ - - ~~use console by default if `NODE_ENV === development`~~ - - ~~use none if not (`logger?.warn()`)~~ -- ~~set prepare scripts where it's needed (web-client)~~ -- add unit tests (config-jest?) -- ~~add reload by terminal input~~ \ No newline at end of file diff --git a/commitlint.config.ts b/commitlint.config.ts new file mode 100644 index 0000000..ae51b31 --- /dev/null +++ b/commitlint.config.ts @@ -0,0 +1,6 @@ +export default { + extends: [ + "@commitlint/config-conventional", + "@commitlint/config-lerna-scopes", + ], +}; diff --git a/examples/client-web/.eslintignore b/examples/client-web/.eslintignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/client-web/.eslintignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/client-web/.eslintrc b/examples/client-web/.eslintrc new file mode 100644 index 0000000..0686197 --- /dev/null +++ b/examples/client-web/.eslintrc @@ -0,0 +1,4 @@ +{ + "root": true, + "extends": ["@lessstack/eslint-config"] +} diff --git a/examples/client-web/.gitignore b/examples/client-web/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/client-web/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/client-web/.prettierrc b/examples/client-web/.prettierrc new file mode 100644 index 0000000..6b8410b --- /dev/null +++ b/examples/client-web/.prettierrc @@ -0,0 +1 @@ +"@lessstack/prettier-config" diff --git a/examples/client-web/lessstack-env.d.ts b/examples/client-web/lessstack-env.d.ts new file mode 100644 index 0000000..696035f --- /dev/null +++ b/examples/client-web/lessstack-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/client-web/package.json b/examples/client-web/package.json new file mode 100644 index 0000000..79ac684 --- /dev/null +++ b/examples/client-web/package.json @@ -0,0 +1,35 @@ +{ + "name": "@lessstack/example-client-web", + "version": "0.1.6", + "main": "build/node.js", + "license": "MIT", + "author": "Cahnory (https://github.com/cahnory)", + "repository": "https://github.com/lessstack/lessstack", + "private": true, + "scripts": { + "build": "webpack", + "dev": "webpack --watch", + "format": "eslint . --fix", + "lint": "eslint . && tsc --noEmit" + }, + "devDependencies": { + "@lessstack/eslint-config": "workspace:*", + "@lessstack/prettier-config": "workspace:*", + "@lessstack/typescript-config": "workspace:*", + "@lessstack/webpack-config": "workspace:*", + "@types/react": "^18.0.25", + "@vanilla-extract/webpack-plugin": "^2.2.0", + "eslint": "^8.27.0", + "prettier": "^2.4.1", + "typescript": "^4.7.4", + "webpack": "^5.74.0", + "webpack-cli": "^4.10.0" + }, + "dependencies": { + "@lessstack/react": "workspace:*", + "@vanilla-extract/css": "^1.9.1", + "@vanilla-extract/recipes": "^0.2.5", + "react": "^18.2.0", + "react-router-dom": "^6.4.3" + } +} diff --git a/examples/client-web/src/app.tsx b/examples/client-web/src/app.tsx new file mode 100644 index 0000000..7698c72 --- /dev/null +++ b/examples/client-web/src/app.tsx @@ -0,0 +1,25 @@ +import { Config, loadable } from "@lessstack/react"; +import { Route, Routes } from "react-router-dom"; +import type { FC } from "react"; + +import routes from "./routes"; + +import logo from "./logo.svg"; + +const Error404 = loadable(() => import("./pages/Error404")); +const PageA = loadable(() => import("./pages/PageA")); +const PageB = loadable(() => import("./pages/PageB")); + +const App: FC = () => ( + + Hello world + + } /> + } /> + } /> + + + +); + +export default App; diff --git a/examples/client-web/src/browser.tsx b/examples/client-web/src/browser.tsx new file mode 100644 index 0000000..5b65e62 --- /dev/null +++ b/examples/client-web/src/browser.tsx @@ -0,0 +1,13 @@ +import { hydrate } from "@lessstack/react"; +import { BrowserRouter } from "react-router-dom"; +import type { FC } from "react"; + +import App from "./app"; + +const BrowserApp: FC<{ basename: string }> = ({ basename }) => ( + + + +); + +hydrate(BrowserApp); diff --git a/examples/client-web/src/components/Button/Button.d.ts b/examples/client-web/src/components/Button/Button.d.ts new file mode 100644 index 0000000..c13ee38 --- /dev/null +++ b/examples/client-web/src/components/Button/Button.d.ts @@ -0,0 +1,10 @@ +import type { ButtonHTMLAttributes, FC, ReactNode } from "react"; + +declare type ButtonProps = { + children: ReactNode; + isDisabled: boolean; + type?: ButtonHTMLAttributes["type"]; +}; +declare const Button: FC; +export default Button; +// # sourceMappingURL=Button.d.ts.map diff --git a/examples/client-web/src/components/Button/Button.d.ts.map b/examples/client-web/src/components/Button/Button.d.ts.map new file mode 100644 index 0000000..e7bd538 --- /dev/null +++ b/examples/client-web/src/components/Button/Button.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["Button.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEjE,aAAK,WAAW,GAAG;IACjB,QAAQ,EAAE,SAAS,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,oBAAoB,CAAC,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;CACxD,CAAC;AAEF,QAAA,MAAM,MAAM,EAAE,EAAE,CAAC,WAAW,CAQ3B,CAAC;AAEF,eAAe,MAAM,CAAC"} \ No newline at end of file diff --git a/examples/client-web/src/components/Button/Button.tsx b/examples/client-web/src/components/Button/Button.tsx new file mode 100644 index 0000000..1e4b65c --- /dev/null +++ b/examples/client-web/src/components/Button/Button.tsx @@ -0,0 +1,19 @@ +import type { ButtonHTMLAttributes, FC, ReactNode } from "react"; + +type ButtonProps = { + children: ReactNode; + isDisabled: boolean; + type?: ButtonHTMLAttributes["type"]; +}; + +const Button: FC = ({ + children, + isDisabled, + type = "button", +}: ButtonProps) => ( + +); + +export default Button; diff --git a/examples/client-web/src/logo.svg b/examples/client-web/src/logo.svg new file mode 100644 index 0000000..9dfc1c0 --- /dev/null +++ b/examples/client-web/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/client-web/src/node.tsx b/examples/client-web/src/node.tsx new file mode 100644 index 0000000..ef799a9 --- /dev/null +++ b/examples/client-web/src/node.tsx @@ -0,0 +1,19 @@ +import { Config } from "@lessstack/react"; +import { createEntry } from "@lessstack/react/node"; +import { MemoryRouter } from "react-router-dom"; +import type { FC } from "react"; + +import App from "./app"; + +const NodeApp: FC<{ basename: string; location: string }> = ({ + basename, + location, +}) => ( + + + + + +); + +export const entry = createEntry(NodeApp); diff --git a/examples/client-web/src/pages/Error404.tsx b/examples/client-web/src/pages/Error404.tsx new file mode 100644 index 0000000..2067ae3 --- /dev/null +++ b/examples/client-web/src/pages/Error404.tsx @@ -0,0 +1,14 @@ +import { Config } from "@lessstack/react"; +import { Link } from "react-router-dom"; +import type { FC } from "react"; + +import routes from "../routes"; + +const Error404: FC = () => ( + +
Error404
+ Back +
+); + +export default Error404; diff --git a/examples/client-web/src/pages/PageA.tsx b/examples/client-web/src/pages/PageA.tsx new file mode 100644 index 0000000..1b2eb7c --- /dev/null +++ b/examples/client-web/src/pages/PageA.tsx @@ -0,0 +1,13 @@ +import { Link } from "react-router-dom"; +import type { FC } from "react"; + +import routes from "../routes"; + +const PageA: FC = () => ( + <> +
PageA
+ PageB + +); + +export default PageA; diff --git a/examples/client-web/src/pages/PageB.tsx b/examples/client-web/src/pages/PageB.tsx new file mode 100644 index 0000000..2d20e13 --- /dev/null +++ b/examples/client-web/src/pages/PageB.tsx @@ -0,0 +1,13 @@ +import { Link } from "react-router-dom"; +import type { FC } from "react"; + +import routes from "../routes"; + +const PageB: FC = () => ( + <> +
PageB
+ PageA + +); + +export default PageB; diff --git a/examples/client-web/src/routes.ts b/examples/client-web/src/routes.ts new file mode 100644 index 0000000..52303f5 --- /dev/null +++ b/examples/client-web/src/routes.ts @@ -0,0 +1,7 @@ +const routes = { + pageA: "/", + pageB: "/B", + root: "/", +}; + +export default routes; diff --git a/examples/client-web/tsconfig.json b/examples/client-web/tsconfig.json new file mode 100644 index 0000000..0909e09 --- /dev/null +++ b/examples/client-web/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@lessstack/typescript-config/react.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "build"], + "files": ["lessstack-env.d.ts"] +} diff --git a/examples/client-web/webpack.config.js b/examples/client-web/webpack.config.js new file mode 100644 index 0000000..ced722a --- /dev/null +++ b/examples/client-web/webpack.config.js @@ -0,0 +1,11 @@ +/* eslint-env node */ +const { VanillaExtractPlugin } = require("@vanilla-extract/webpack-plugin"); +const { createConfig } = require("@lessstack/webpack-config/build"); + +module.exports = createConfig({ + postProcess: (configs) => + configs.map((config) => { + config.plugins.push(new VanillaExtractPlugin()); + return config; + }), +}); diff --git a/examples/server-web/.eslintignore b/examples/server-web/.eslintignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/server-web/.eslintignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/server-web/.eslintrc b/examples/server-web/.eslintrc new file mode 100644 index 0000000..0686197 --- /dev/null +++ b/examples/server-web/.eslintrc @@ -0,0 +1,4 @@ +{ + "root": true, + "extends": ["@lessstack/eslint-config"] +} diff --git a/examples/server-web/.gitignore b/examples/server-web/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/server-web/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/server-web/.prettierrc b/examples/server-web/.prettierrc new file mode 100644 index 0000000..6b8410b --- /dev/null +++ b/examples/server-web/.prettierrc @@ -0,0 +1 @@ +"@lessstack/prettier-config" diff --git a/examples/server-web/nodemon.json b/examples/server-web/nodemon.json new file mode 100644 index 0000000..6cc25c7 --- /dev/null +++ b/examples/server-web/nodemon.json @@ -0,0 +1,11 @@ +{ + "ignoreRoot": [ + "coverage", + "*.log*", + ".DS_Store", + "*.pem", + ".turbo", + "node_modules/!(@lessstack/example-client-web)" + ], + "watch": ["build", "node_modules/@lessstack/example-client-web"] +} diff --git a/examples/server-web/package.json b/examples/server-web/package.json new file mode 100644 index 0000000..4ecd0fe --- /dev/null +++ b/examples/server-web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@lessstack/example-server-web", + "version": "0.1.6", + "main": "build/index.js", + "license": "MIT", + "author": "Cahnory (https://github.com/cahnory)", + "repository": "https://github.com/lessstack/lessstack", + "private": true, + "scripts": { + "build": "tsc --outDir build", + "dev": "tsc --outDir build --watch & nodemon build/index", + "format": "eslint . --fix", + "lint": "eslint . && tsc --noEmit", + "start": "node build/index" + }, + "devDependencies": { + "@lessstack/eslint-config": "workspace:*", + "@lessstack/prettier-config": "workspace:*", + "@lessstack/react": "workspace:*", + "@lessstack/typescript-config": "workspace:*", + "@types/express": "^4.17.14", + "@types/node": "^18.11.9", + "eslint": "^8.27.0", + "nodemon": "^2.0.20", + "prettier": "^2.4.1", + "typescript": "^4.7.4" + }, + "dependencies": { + "@lessstack/example-client-web": "workspace:*", + "express": "^4.18.2" + } +} diff --git a/examples/server-web/src/index.ts b/examples/server-web/src/index.ts new file mode 100644 index 0000000..9d5a7b5 --- /dev/null +++ b/examples/server-web/src/index.ts @@ -0,0 +1,40 @@ +import createClient from "@lessstack/example-client-web"; +import express from "express"; +import type { ClientRequest, ServerResponse } from "http"; + +type Request = ClientRequest & { baseUrl: string; originalUrl: string }; +type Response = ServerResponse; + +const app = express(); +const PORT = 3000; + +const { entry: primary } = createClient({ + publicRoute: "/assets", +}); + +const { entry: secondary } = createClient({ + publicRoute: "/secondary/test", +}); +app.use("/secondary/test", express.static(secondary.publicPath)); +app.use("/secondary", (req: Request, res: Response) => { + secondary.streamRendering({ + initialProps: { + basename: req.baseUrl, + location: req.originalUrl, + }, + response: res, + }); +}); + +app.use("/assets", express.static(primary.publicPath)); +app.use((req: Request, res: Response) => { + primary.streamRendering({ + initialProps: { + basename: req.baseUrl, + location: req.originalUrl, + }, + response: res, + }); +}); + +app.listen(PORT); diff --git a/examples/server-web/tsconfig.json b/examples/server-web/tsconfig.json new file mode 100644 index 0000000..64ae426 --- /dev/null +++ b/examples/server-web/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@lessstack/typescript-config/base", + "include": ["**/*.ts", "**/*.tsx", "webpack.config.js"], + "exclude": ["node_modules", "build"] +} diff --git a/lerna.json b/lerna.json deleted file mode 100644 index 6305f8d..0000000 --- a/lerna.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.1.6", - "npmClient": "yarn", - "packages": [ - "packages/*" - ], - "useWorkspaces": true -} diff --git a/package.json b/package.json index 1135de2..13de885 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,39 @@ { "name": "lessstack", - "version": "0.1.1", - "private": "true", - "license": "MIT", - "workspaces": { - "packages": [ - "packages/*" - ] - }, + "version": "0.1.6", + "private": true, + "workspaces": [ + "examples/*", + "packages/*" + ], "scripts": { - "prepare": "lerna run prepare", - "prepublish": "yarn lint", - "lint": "lerna exec -- yarn lint" - }, - "resolutions": { - "json-schema": "^0.4.0", - "semver-regex": ">=3.1.3", - "trim-newlines": ">=3.0.1" + "build": "turbo run build", + "dev": "turbo run build && turbo run dev --parallel", + "format": "turbo run format", + "lint": "turbo run lint", + "prepare": "husky install", + "start": "turbo run start" }, "devDependencies": { - "lerna": "^4.0.0" + "@commitlint/cli": "^17.2.0", + "@commitlint/config-conventional": "^17.2.0", + "@commitlint/config-lerna-scopes": "^17.2.1", + "@lessstack/eslint-config": "workspace:0.1.6", + "@lessstack/prettier-config": "workspace:0.1.6", + "eslint": "^8.27.0", + "husky": "^8.0.2", + "prettier": "^2.7.1", + "turbo": "^1.6.3", + "typescript": "^4.8.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "packageManager": "pnpm@7.5.0", + "pnpm": { + "overrides": { + "semver-regex@<3.1.4": "^3.1.4", + "trim-newlines@<3.0.1": "^3.0.1" + } } } diff --git a/packages/babel-config/.eslintrc b/packages/babel-config/.eslintrc deleted file mode 100644 index f0fadbd..0000000 --- a/packages/babel-config/.eslintrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "root": true, - "extends": ["@lessstack"] -} diff --git a/packages/babel-config/.prettierrc b/packages/babel-config/.prettierrc deleted file mode 100644 index 6301ee6..0000000 --- a/packages/babel-config/.prettierrc +++ /dev/null @@ -1 +0,0 @@ -"@lessstack/prettier-config" \ No newline at end of file diff --git a/packages/babel-config/index.js b/packages/babel-config/index.js deleted file mode 100644 index 6a5595d..0000000 --- a/packages/babel-config/index.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = () => ({ - presets: ["@babel/preset-env", "@babel/preset-react", "@linaria"], - plugins: ["@babel/plugin-transform-runtime", "@loadable/babel-plugin"], -}); diff --git a/packages/babel-config/package.json b/packages/babel-config/package.json deleted file mode 100644 index cd788ce..0000000 --- a/packages/babel-config/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@lessstack/babel-config", - "version": "0.1.6", - "main": "index.js", - "license": "MIT", - "author": "Cahnory (https://github.com/cahnory)", - "repository": "https://github.com/lessstack/lessstack", - "publishConfig": { - "access": "public" - }, - "scripts": { - "lint": "eslint ." - }, - "dependencies": { - "@babel/core": "^7.16.0", - "@babel/plugin-transform-runtime": "^7.16.0", - "@babel/preset-env": "^7.16.0", - "@babel/preset-react": "^7.16.0", - "@babel/runtime": "^7.16.3", - "@linaria/babel-preset": "^3.0.0-beta.13" - }, - "devDependencies": { - "@lessstack/eslint-config": "^0.1.6", - "@lessstack/prettier-config": "^0.1.6", - "eslint": "^8.2.0", - "prettier": "^2.4.1" - } -} diff --git a/packages/clusterizer/.eslintrc b/packages/clusterizer/.eslintrc deleted file mode 100644 index cea61cf..0000000 --- a/packages/clusterizer/.eslintrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "root": true, - "extends": ["@lessstack/eslint-config/esm"] -} diff --git a/packages/clusterizer/.prettierrc b/packages/clusterizer/.prettierrc deleted file mode 100644 index 6301ee6..0000000 --- a/packages/clusterizer/.prettierrc +++ /dev/null @@ -1 +0,0 @@ -"@lessstack/prettier-config" \ No newline at end of file diff --git a/packages/clusterizer/index.js b/packages/clusterizer/index.js deleted file mode 100755 index 68f3821..0000000 --- a/packages/clusterizer/index.js +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from "module"; -import readline from "readline"; -import path from "path"; -import { createPoolLogger, createWatcherLogger } from "./src/logger.js"; -import createPool from "./src/pool.js"; -import createSettings from "./src/settings.js"; -import createWatcher from "./src/watcher.js"; - -const resolveImport = async (basePath, extensions) => { - try { - return await import(`${basePath}${extensions[0]}`); - } catch (e) { - if (extensions.length > 1) { - return resolveImport(basePath, extensions.slice(1)); - } - throw new Error(`Could not resolve ${basePath}`); - } -}; - -// Locate and import config file -let configList; -try { - configList = [].concat( - await resolveImport(path.join(process.cwd(), "./clusterizer.config"), [ - ".js", - ".cjs", - ".mjs", - ".json", - ]).then((result) => { - if (typeof result.default === "function") { - return result.default(); - } - return result.default; - }), - ); -} catch (e) { - configList = [{}]; -} - -// package's main or index.js as config.exec fallback -const execFallback = createRequire(process.cwd()).resolve(process.cwd()); - -// create settings from config -const settingsList = configList.map((config, key) => ({ - name: `pool-${key + 1}`, - ...createSettings({ - exec: execFallback, - ...config, - }), -})); - -if ((process.env.NODE_ENV ?? "development") === "development") { - // eslint-disable-next-line no-console - console.log("Starting clusterizer with settings:", settingsList); - // eslint-disable-next-line no-console - console.table({ shortcuts: { reload: "ctrl-r", stop: "ctrl-c" } }); -} - -// Create and start clusterizers -const clusterizerList = settingsList.map((settings) => { - const pool = createPool(process); - let watcher; - - createPoolLogger(pool, settings); - pool.start(settings); - if (settings.watch.length) { - watcher = createWatcher(() => { - pool.reload(); - }, settings); - createWatcherLogger(pool, watcher, settings); - watcher.start(); - } - - return { pool, watcher, settings }; -}); - -const stop = async () => { - await Promise.all( - clusterizerList.map(({ pool, watcher }) => - Promise.all([ - pool.isStoppable() && pool.stop(), - watcher && watcher.stop(), - ]), - ), - ); - - process.exit(0); -}; - -const reload = () => { - clusterizerList.forEach((clusterizer) => { - clusterizer.pool.reload(); - }); -}; - -// Reload clusterizers pool using stdin data -readline.emitKeypressEvents(process.stdin); -process.stdin.setRawMode(true); -process.stdin.resume(); -process.stdin.setEncoding("utf-8"); -process.stdin.on("keypress", (data, { name, ctrl }) => { - if (ctrl && name === "c") { - stop(); - } else if (ctrl && name === "r") { - reload(); - } -}); - -// Stop clusterizers on SIGINT -process.on("SIGINT", stop); diff --git a/packages/clusterizer/package.json b/packages/clusterizer/package.json deleted file mode 100644 index f761820..0000000 --- a/packages/clusterizer/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@lessstack/clusterizer", - "version": "0.1.6", - "main": "src/pool.js", - "license": "MIT", - "author": "Cahnory (https://github.com/cahnory)", - "repository": "https://github.com/lessstack/lessstack", - "publishConfig": { - "access": "public" - }, - "type": "module", - "bin": { - "clusterizer": "./index.js" - }, - "scripts": { - "lint": "eslint ." - }, - "dependencies": { - "chokidar": "^3.5.2" - }, - "devDependencies": { - "@lessstack/eslint-config": "^0.1.6", - "@lessstack/prettier-config": "^0.1.6", - "eslint": "^8.2.0", - "prettier": "^2.4.1" - } -} diff --git a/packages/clusterizer/src/eventEmitter.js b/packages/clusterizer/src/eventEmitter.js deleted file mode 100644 index d2f7b1c..0000000 --- a/packages/clusterizer/src/eventEmitter.js +++ /dev/null @@ -1,20 +0,0 @@ -import { EventEmitter } from "events"; - -const createEventEmitter = () => { - const emitter = new EventEmitter(); - return { - emit: emitter.emit.bind(emitter), - on: (event, listener) => { - const cb = listener.bind(null); - emitter.on(event, cb); - return () => emitter.off(event, cb); - }, - prepend: (event, listener) => { - const cb = listener.bind(null); - emitter.prependListener(event, cb); - return () => emitter.off(event, cb); - }, - }; -}; - -export default createEventEmitter; diff --git a/packages/clusterizer/src/fork.js b/packages/clusterizer/src/fork.js deleted file mode 100644 index e477f5b..0000000 --- a/packages/clusterizer/src/fork.js +++ /dev/null @@ -1,122 +0,0 @@ -import cluster from "cluster"; -import createEventEmitter from "./eventEmitter.js"; - -export const STATES = { - STOPPED: "STOPPED", - STARTED: "STARTED", - READY: "READY", - STOPPING: "STOPPING", - FAILED: "FAILED", -}; - -const createFork = () => { - let state = STATES.STOPPED; - let worker = null; - let settings = {}; - const emitter = createEventEmitter(); - - const setState = (newState, data) => { - state = newState; - emitter.emit("state", { worker, state, data }); - }; - - const getState = () => state; - - const setup = cluster.setupPrimary || cluster.setupMaster; - - const ready = (context) => { - switch (state) { - case STATES.STARTED: { - setState(STATES.READY, context); - break; - } - default: - } - }; - - const stop = () => { - switch (state) { - case STATES.STARTED: - case STATES.READY: { - setState(STATES.STOPPING); - worker.kill(settings.killSignal); - break; - } - default: - throw new Error(`Trying to kill a fork with "${state}" state`); - } - }; - - const start = (newSettings) => { - settings = newSettings; - switch (state) { - case STATES.STOPPED: - case STATES.FAILED: { - const previousSettings = cluster.settings; - setup({ - args: settings.args, - exec: settings.exec, - execArgv: settings.execArgv, - silent: true, - }); - worker = cluster.fork(settings.env); - setup(previousSettings); - setState(STATES.STARTED); - - worker.on("exit", (code, signal) => { - setState(code ? STATES.FAILED : STATES.STOPPED, { code, signal }); - }); - worker.on("error", () => { - switch (state) { - case STATES.STOPPING: { - worker.process.kill(settings.killSignal); - break; - } - default: { - stop(); - } - } - }); - - worker.process.stdout.on("data", (data) => { - emitter.emit("stdout", { worker, data }); - }); - worker.process.stderr.on("data", (data) => { - emitter.emit("stderr", { worker, data }); - }); - - if (settings.readyMessage) { - worker.on("message", (message) => { - if (message === settings.readyMessage) { - ready({ trigger: "message", message }); - } - }); - } - - if (settings.readyTimeout) { - setTimeout(() => { - ready({ trigger: "timeout", delay: settings.readyTimeout }); - }, settings.readyTimeout); - } - - if (settings.readyWhenOnline) { - worker.on("online", () => { - ready({ trigger: "online" }); - }); - } - break; - } - default: - throw new Error(`Trying to start a fork with "${state}" state`); - } - }; - - return { - stop, - start, - getState, - ...emitter, - }; -}; - -export default createFork; diff --git a/packages/clusterizer/src/logger.js b/packages/clusterizer/src/logger.js deleted file mode 100644 index 4eafed9..0000000 --- a/packages/clusterizer/src/logger.js +++ /dev/null @@ -1,239 +0,0 @@ -import { STATES as FORK_STATES } from "./fork.js"; -import { STATES as POOL_STATES } from "./pool.js"; -import { STATES as WATCHER_STATES } from "./watcher.js"; - -const INSTANCE_STATE_COLORS = { - [FORK_STATES.STOPPED]: "\x1b[33m", - [FORK_STATES.STARTED]: "\x1b[36m", - [FORK_STATES.READY]: "\x1b[32m", - [FORK_STATES.STOPPING]: "", - [FORK_STATES.FAILED]: "\x1b[31m", -}; -const POOL_STATE_COLORS = { - [POOL_STATES.STOPPED]: "\x1b[33m", - [POOL_STATES.STARTED]: "\x1b[36m", - [POOL_STATES.STOPPING]: "", - [POOL_STATES.RELOADING]: "\x1b[36m", - [POOL_STATES.READY]: "\x1b[32m", - [POOL_STATES.FAILED]: "\x1b[31m", -}; -const WATCHER_STATE_COLORS = { - [WATCHER_STATES.STOPPED]: "\x1b[33m", - [WATCHER_STATES.FAILED]: "\x1b[31m", - [WATCHER_STATES.WATCHING]: "\x1b[32m", - [WATCHER_STATES.STOPPING]: "", -}; - -const MODIFIERS = { - bold: "1m", - dim: "2m", - cursive: "3m", - underline: "4m", - blink: "5m", - reversed: "7m", - hidden: "8m", -}; - -/* eslint-disable no-control-regex */ -const REGEX = new RegExp( - `(?:${[ - ...Object.entries(MODIFIERS).map( - ([modifier, code]) => `(?<${modifier}>${/\x1b\[/.source}${code}})`, - ), - `(?${/\x1b\[0m/.source})`, - `(?${ - /\x1b\[(3[0-7](?:;1)?|38;5;(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))m/ - .source - })`, - `(?${ - /\x1b\[(4[0-7](?:;1)?|38;5;(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))m/ - .source - })`, - ].join("|")})`, - "g", -); -/* eslint-enable no-control-regex */ - -const getNoStyles = () => ({ - ...Object.keys(MODIFIERS).reduce((acc, name) => { - acc[name] = false; - return acc; - }, {}), - foreground: null, - background: null, -}); - -const getNextStyles = (input, styles) => { - const exec = () => REGEX.exec(input); - let nextStyles = { ...styles }; - REGEX.lastIndex = 0; - - for (let res = exec(); res; res = exec()) { - const modifier = Object.keys(MODIFIERS).find((name) => res.groups[name]); - if (modifier) { - nextStyles[modifier] = true; - } else if (res.groups.foreground) { - nextStyles.foreground = res.groups.foreground; - } else if (res.groups.background) { - nextStyles.background = res.groups.background; - } else if (res.groups.reset) { - nextStyles = getNoStyles(); - } - } - - return nextStyles; -}; - -const getStylesCodes = (styles) => { - let codes = ""; - - Object.entries(MODIFIERS).forEach(([modifier, code]) => { - if (styles[modifier]) { - codes += `\x1b[${code}`; - } - }); - if (styles.background) { - codes += styles.background; - } - if (styles.foreground) { - codes += styles.foreground; - } - - return codes; -}; - -export const createCanal = (target = process) => { - let styles = getNoStyles(); - return { - stdout: (data) => { - target.stdout.write("\x1b[0m"); - const codes = getStylesCodes(styles); - styles = getNextStyles(data, styles); - target.stdout.write(codes); - target.stdout.write(data); - target.stdout.write("\x1b[0m"); - }, - stderr: (data) => { - const codes = getStylesCodes(styles); - styles = getNextStyles(data, styles); - target.stderr.write("\x1b[0m"); - target.stderr.write(codes); - target.stderr.write(data); - target.stderr.write("\x1b[0m"); - }, - }; -}; - -let maxPidLength = Math.max(`${process.pid}`.length, "watcher".length); -const getTimePrefix = () => - `\x1b[2m${new Date().toISOString().replace(/[ZT]/g, " ")}\x1b[0m`; -const getPoolPrefix = (pool) => - `${getTimePrefix()}\x1b[36m${pool.getName()}\x1b[0m ${" ".repeat( - maxPidLength, - )} `; -const getInstancePrefix = (pool, worker) => - `${getTimePrefix()}\x1b[36m${pool.getName()}\x1b[0m:\x1b[33m${ - worker.process.pid - }${" ".repeat(maxPidLength - `${worker.process.pid}`.length)}\x1b[0m `; -const getWatcherPrefix = (pool) => - `${getTimePrefix()}\x1b[36m${pool.getName()}\x1b[0m:\x1b[34mwatcher\x1b[0m `; - -export const createInstanceLogger = ( - pool, - instance, - settings, - target = process, -) => { - let instanceCanal; - - instance.prepend("state", ({ state, worker }) => { - if (state === "STARTED") { - instanceCanal = createCanal(target); - maxPidLength = Math.max(maxPidLength, `${worker.process.pid}`.length); - } - if (settings.logs.workerState) { - target.stdout.write(getInstancePrefix(pool, worker)); - target.stdout.write(`${INSTANCE_STATE_COLORS[state]}${state}\x1b[0m\n`); - } - }); - - if (settings.logs.workerOut) { - instance.on("stdout", ({ data, worker }) => { - `${data}`.split(/\n/g).forEach((line, key, lines) => { - if (line || key < lines.length - 1) { - if (settings.logs.prefixWorkerOut) { - target.stdout.write(getInstancePrefix(pool, worker)); - } - instanceCanal.stdout(line); - } - if (key < lines.length - 1) { - instanceCanal.stdout("\n"); - } - }); - }); - } - - if (settings.logs.workerErr) { - instance.on("stderr", ({ data, worker }) => { - `${data}`.split(/\n/g).forEach((line, key, lines) => { - if (line || key < lines.length - 1) { - if (settings.logs.prefixWorkerErr) { - target.stdout.write(getInstancePrefix(pool, worker)); - } - instanceCanal.stderr(line); - } - if (key < lines.length - 1) { - instanceCanal.stderr("\n"); - } - }); - }); - } -}; - -export const createPoolLogger = (pool, settings, target = process) => { - if (settings.logs.poolState) { - pool.on("state", ({ state }) => { - target.stdout.write(getPoolPrefix(pool, maxPidLength)); - target.stdout.write( - `\x1b[7m${POOL_STATE_COLORS[state]} ${state} \x1b[0m\n`, - ); - }); - } - - if ( - settings.logs.workerState || - settings.logs.workerOut || - settings.logs.workerErr - ) { - pool.on("instance", ({ instance }) => - createInstanceLogger(pool, instance, settings, target), - ); - } -}; - -export const createWatcherLogger = ( - pool, - watcher, - settings, - target = process, -) => { - if (settings.logs.watcherState) { - watcher.on("state", ({ state }) => { - target.stdout.write(getWatcherPrefix(pool, maxPidLength)); - target.stdout.write(`${WATCHER_STATE_COLORS[state]}${state}\x1b[0m\n`); - }); - } - if (settings.logs.watcherUpdate) { - watcher.on("update", ({ updates }) => { - const prefix = getWatcherPrefix(pool, maxPidLength); - - target.stdout.write(prefix); - target.stdout.write(`reloaded by:\n`); - - updates.forEach((path) => { - target.stdout.write(prefix); - target.stdout.write(`- ${path}\n`); - }); - }); - } -}; diff --git a/packages/clusterizer/src/pool.js b/packages/clusterizer/src/pool.js deleted file mode 100644 index ecae94d..0000000 --- a/packages/clusterizer/src/pool.js +++ /dev/null @@ -1,277 +0,0 @@ -import createEventEmitter from "./eventEmitter.js"; -import createFork, { STATES as FORK_STATES } from "./fork.js"; - -export const STATES = { - STOPPED: "STOPPED", - STARTED: "STARTED", - STOPPING: "STOPPING", - RELOADING: "RELOADING", - FAILED: "FAILED", - READY: "READY", -}; - -const removeArrayItem = (array, item) => { - const index = array.indexOf(item); - return [...array.slice(0, index), ...array.slice(index + 1)]; -}; - -const createPool = () => { - let instances = []; - let obsoleteInstances = []; - let state = STATES.STOPPED; - let settings = {}; - let failures = 0; - const emitter = createEventEmitter(); - - const setState = (newState, data) => { - state = newState; - emitter.emit("state", { state, data }); - }; - - const isStoppable = () => - [STATES.STARTED, STATES.READY, STATES.RELOADING].includes(state); - - const isStartable = () => [STATES.FAILED, STATES.STOPPED].includes(state); - - const updateInstances = () => { - if (!settings.maxFailures || failures <= settings.maxFailures) { - const readyInstances = instances.filter( - (instance) => instance.getState() === FORK_STATES.READY, - ); - const readyStoppableCount = readyInstances.length - settings.minInstances; - - // stop obsolete started instances if minInstances is reached - if (readyStoppableCount >= 0) { - instances.forEach((instance) => { - if ( - obsoleteInstances.includes(instance) && - instance.getState() === FORK_STATES.STARTED - ) { - instance.stop(); - } - }); - } - - // start idle instances - instances.forEach((instance) => { - const forkState = instance.getState(); - if ( - forkState === FORK_STATES.STOPPED || - forkState === FORK_STATES.FAILED - ) { - obsoleteInstances = removeArrayItem(obsoleteInstances, instance); - instances = removeArrayItem(instances, instance).concat(instance); - instance.start(settings); - } - }); - - // stop obsolete ready instances if minInstances is exceeded - if (readyStoppableCount > 0) { - readyInstances - .filter((instance) => obsoleteInstances.includes(instance)) - .slice(0, readyStoppableCount) - .forEach((instance) => instance.stop()); - } - } else if ( - !instances.find((instance) => instance.getState() !== FORK_STATES.FAILED) - ) { - setState(STATES.FAILED); - } - }; - - const forgetInstance = (instance) => { - if (!instances.includes(instance)) { - throw new Error("Trying to forget an unknown instance"); - } - - instances = removeArrayItem(instances, instance); - }; - - const startNewInstance = () => { - const newInstance = createFork(); - newInstance.on("state", ({ state: forkState }) => { - switch (forkState) { - case FORK_STATES.FAILED: - case FORK_STATES.STOPPED: { - const hasFailed = forkState === FORK_STATES.FAILED; - // up to date fork failed - if (!obsoleteInstances.includes(newInstance) && hasFailed) { - failures += 1; - } - - switch (state) { - case STATES.RELOADING: - if (instances.length > settings.maxInstances) { - forgetInstance(newInstance); - } else { - updateInstances(); - } - break; - case STATES.STARTED: - case STATES.READY: - if (hasFailed) { - updateInstances(); - } - break; - case STATES.STOPPING: - forgetInstance(newInstance); - if (!instances.length) { - setState(STATES.STOPPED); - } - break; - default: - break; - } - break; - } - case FORK_STATES.READY: { - // all forks ready and up to date - if ( - !instances.some( - (instance) => - instance.getState() !== FORK_STATES.READY || - obsoleteInstances.includes(instance), - ) - ) { - setState(STATES.READY); - } else { - updateInstances(); - } - break; - } - default: - } - }); - - emitter.emit("instance", { instance: newInstance }); - - instances = instances.concat(newInstance); - newInstance.start(settings); - - return newInstance; - }; - - const start = async (newSettings) => { - if (isStartable()) { - failures = 0; - settings = newSettings; - setState(STATES.STARTED, newSettings); - - const output = new Promise((resolve) => { - const removeListener = emitter.on("state", ({ state: newState }) => { - removeListener(); - switch (newState) { - case STATES.READY: { - resolve(true); - break; - } - default: { - resolve(false); - } - } - }); - }); - - for (let i = 0; i < settings.maxInstances; i += 1) { - startNewInstance(); - } - - return output; - } - - throw new Error(`Trying to start pool in "${state}" state`); - }; - - const stop = async () => { - if (isStoppable()) { - setState(STATES.STOPPING); - const output = new Promise((resolve) => { - const removeListener = emitter.on("state", ({ state: newState }) => { - removeListener(); - switch (newState) { - case STATES.STOPPED: { - resolve(true); - break; - } - default: { - resolve(false); - } - } - }); - }); - - instances.forEach((instance) => { - switch (instance.getState()) { - case FORK_STATES.STARTED: - case FORK_STATES.READY: - instance.stop(); - break; - default: - break; - } - }); - - return output; - } - - throw new Error(`Trying to stop pool in "${state}" state`); - }; - - const reload = (newSettings) => { - switch (state) { - case STATES.FAILED: - case STATES.STARTED: - case STATES.RELOADING: - case STATES.READY: { - failures = 0; - settings = newSettings ?? settings; - setState(STATES.RELOADING, settings); - obsoleteInstances = instances; - - const missingInstances = Math.max( - 0, - settings.maxInstances - instances.length, - ); - const output = new Promise((resolve) => { - const removeListener = emitter.on("state", ({ state: newState }) => { - removeListener(); - switch (newState) { - case STATES.READY: { - resolve(true); - break; - } - default: { - resolve(false); - } - } - }); - }); - - for (let i = 0; i < missingInstances; i += 1) { - startNewInstance(); - } - - updateInstances(); - - return output; - } - default: { - throw new Error(`Trying to reload pool in "${state}" state`); - } - } - }; - - const getName = () => settings.name; - - return { - start, - stop, - reload, - getName, - isStartable, - isStoppable, - ...emitter, - }; -}; - -export default createPool; diff --git a/packages/clusterizer/src/settings.js b/packages/clusterizer/src/settings.js deleted file mode 100644 index 23d91b9..0000000 --- a/packages/clusterizer/src/settings.js +++ /dev/null @@ -1,79 +0,0 @@ -import { cpus } from "os"; - -const DEFAULT_KILL_SIGNAL = "SIGINT"; -const DEFAULT_MAX_INSTANCES = cpus().length; -const DEFAULT_WATCH_DELAY = 200; -const DEFAULT_WATCH_MAX_DELAY = 500; -const DEFAULT_WATCH_OPTIONS = { ignoreInitial: true }; - -const createSettings = (options) => { - const config = {}; - - // FORK related settings - // Execution - config.env = options.env || {}; - config.args = [].concat(options.args ?? []); - config.exec = options.exec; - config.execArgv = [].concat(options.execArgv ?? []); - - // Ready signal strategy - config.readyMessage = options.readyMessage || null; - config.readyTimeout = options.readyTimeout || null; - config.readyWhenOnline = - options.readyWhenOnline ?? (!config.readyMessage && !config.readyTimeout); - - // Killing - config.killSignal = options.killSignal ?? DEFAULT_KILL_SIGNAL; - - if (!config.exec || typeof config.exec !== "string") { - throw new Error("options.exec must be a non empty string"); - } - - // POOL related settings - config.maxInstances = Math.max( - 1, - options.maxInstances ?? DEFAULT_MAX_INSTANCES, - ); - config.minInstances = Math.max( - 0, - Math.min( - config.maxInstances - 1, - options.minInstances < 0 - ? config.maxInstances + options.minInstances - : options.minInstances ?? 1, - ), - ); - config.maxFailures = Math.max(0, options.maxFailures ?? 1) || 0; - - // WATCHER related settings - config.watch = [].concat(options.watch || []); - config.watchDelay = config.watch.length - ? options.watchDelay ?? DEFAULT_WATCH_DELAY - : null; - config.watchMaxDelay = config.watch.length - ? options.watchMaxDelay ?? DEFAULT_WATCH_MAX_DELAY - : null; - config.watchOptions = config.watch.length - ? { - ...DEFAULT_WATCH_OPTIONS, - ...(options.watchOptions || {}), - } - : null; - - // LOGGER related settings - config.logs = !!(options.logs ?? false); - config.logs = { - workerOut: options.logs?.workerOut ?? true, - workerErr: options.logs?.workerErr ?? true, - poolState: options.logs?.poolState ?? config.logs, - workerState: options.logs?.workerState ?? config.logs, - watcherState: options.logs?.watcherState ?? config.logs, - watcherUpdate: options.logs?.watcherUpdate ?? config.logs, - prefixWorkerOut: options.logs?.prefixWorkerOut ?? config.logs, - prefixWorkerErr: options.logs?.prefixWorkerErr ?? config.logs, - }; - - return config; -}; - -export default createSettings; diff --git a/packages/clusterizer/src/watcher.js b/packages/clusterizer/src/watcher.js deleted file mode 100644 index ad52e6d..0000000 --- a/packages/clusterizer/src/watcher.js +++ /dev/null @@ -1,100 +0,0 @@ -import chokidar from "chokidar"; -import createEventEmitter from "./eventEmitter.js"; - -export const STATES = { - STOPPED: "STOPPED", - FAILED: "FAILED", - WATCHING: "WATCHING", - STOPPING: "STOPPING", -}; - -const defer = (cb) => { - const deferred = {}; - deferred.promise = new Promise((resolve, reject) => { - deferred.resolve = resolve; - deferred.reject = reject; - }); - - if (cb) { - cb(deferred); - } - - return deferred; -}; - -const createWatcher = (callback, settings) => { - const emitter = createEventEmitter(); - let state = STATES.STOPPED; - let watcher; - let deferredStop; - let pendingPaths; - let pendingWatchUpdate; - let pendingMaxWatchUpdate; - - const setState = (newState) => { - state = newState; - emitter.emit("state", { state }); - }; - - const reload = () => { - emitter.emit("update", { updates: pendingPaths }); - callback(pendingPaths); - clearTimeout(pendingWatchUpdate); - clearTimeout(pendingMaxWatchUpdate); - pendingPaths = []; - }; - - const onWatchEvent = (path) => { - if (!pendingPaths.includes(path)) { - pendingPaths.push(path); - } - - if (pendingWatchUpdate) { - clearTimeout(pendingWatchUpdate); - } else { - pendingMaxWatchUpdate = setTimeout(reload, settings.watchMaxDelay); - } - - pendingWatchUpdate = setTimeout(reload, settings.watchDelay); - }; - - return { - start: () => { - deferredStop?.reject(); - deferredStop = null; - pendingPaths = []; - watcher = chokidar.watch(settings.watch, settings.watchOptions); - watcher - .on("add", onWatchEvent) - .on("change", onWatchEvent) - .on("unlink", onWatchEvent) - .on("addDir", onWatchEvent) - .on("unlinkDir", onWatchEvent); - - setState(STATES.WATCHING); - }, - stop: () => { - deferredStop = defer(async (deferred) => { - setState(STATES.STOPPING); - pendingPaths = null; - clearTimeout(pendingWatchUpdate); - clearTimeout(pendingMaxWatchUpdate); - try { - await watcher.close(); - watcher = null; - deferredStop = null; - setState(STATES.STOPPED); - deferred.resolve(); - } catch (error) { - deferredStop = null; - setState(STATES.FAILED); - deferred.reject(error); - } - }); - return deferredStop.promise; - }, - ...emitter, - }; -}; - -export default createWatcher; diff --git a/packages/eslint-config/.eslintignore b/packages/eslint-config/.eslintignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/packages/eslint-config/.eslintignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/packages/eslint-config/.eslintrc b/packages/eslint-config/.eslintrc deleted file mode 100644 index c594521..0000000 --- a/packages/eslint-config/.eslintrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "root": true, - "extends": ["."] -} diff --git a/packages/eslint-config/.eslintrc.json b/packages/eslint-config/.eslintrc.json new file mode 100644 index 0000000..73df5e5 --- /dev/null +++ b/packages/eslint-config/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "root": true, + "extends": ["."], + "env": { + "node": true + } +} diff --git a/packages/eslint-config/.gitignore b/packages/eslint-config/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/packages/eslint-config/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/packages/eslint-config/.prettierrc b/packages/eslint-config/.prettierrc index 6301ee6..6b8410b 100644 --- a/packages/eslint-config/.prettierrc +++ b/packages/eslint-config/.prettierrc @@ -1 +1 @@ -"@lessstack/prettier-config" \ No newline at end of file +"@lessstack/prettier-config" diff --git a/packages/eslint-config/base.d.ts b/packages/eslint-config/base.d.ts new file mode 100644 index 0000000..093a7c2 --- /dev/null +++ b/packages/eslint-config/base.d.ts @@ -0,0 +1 @@ +export * from "./build/base"; diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js new file mode 100644 index 0000000..69ce911 --- /dev/null +++ b/packages/eslint-config/base.js @@ -0,0 +1 @@ +module.exports = require("./build/base"); diff --git a/packages/eslint-config/esm.d.ts b/packages/eslint-config/esm.d.ts new file mode 100644 index 0000000..0650de9 --- /dev/null +++ b/packages/eslint-config/esm.d.ts @@ -0,0 +1 @@ +export * from "./build/esm"; diff --git a/packages/eslint-config/esm.js b/packages/eslint-config/esm.js index b9ef5a3..bbf049e 100644 --- a/packages/eslint-config/esm.js +++ b/packages/eslint-config/esm.js @@ -1,12 +1 @@ -module.exports = { - root: true, - extends: ["."], - rules: { - "import/extensions": [ - "error", - { - js: "ignorePackages", - }, - ], - }, -}; +module.exports = require("./build/esm"); diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js deleted file mode 100644 index 68301bd..0000000 --- a/packages/eslint-config/index.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - env: { - es2021: true, - }, - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - }, - plugins: ["react", "prettier"], - extends: ["plugin:react/recommended", "airbnb", "prettier"], - reportUnusedDisableDirectives: true, - rules: { - "no-use-before-define": ["error", { variables: false }], - "prettier/prettier": ["error"], - "react/jsx-filename-extension": ["off"], - }, - overrides: [ - { - files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"], - env: { - jest: true, - }, - }, - ], -}; diff --git a/packages/eslint-config/jest.d.ts b/packages/eslint-config/jest.d.ts new file mode 100644 index 0000000..1d98186 --- /dev/null +++ b/packages/eslint-config/jest.d.ts @@ -0,0 +1 @@ +export * from "./build/jest"; diff --git a/packages/eslint-config/jest.js b/packages/eslint-config/jest.js new file mode 100644 index 0000000..4a098d4 --- /dev/null +++ b/packages/eslint-config/jest.js @@ -0,0 +1 @@ +module.exports = require("./build/jest"); diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 6ec6a85..0aac659 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,28 +1,41 @@ { "name": "@lessstack/eslint-config", "version": "0.1.6", - "main": "index.js", + "main": "build/index.js", "license": "MIT", "author": "Cahnory (https://github.com/cahnory)", "repository": "https://github.com/lessstack/lessstack", - "publishConfig": { - "access": "public" - }, "scripts": { - "lint": "eslint ." + "build": "tsc --outDir build", + "dev": "tsc --outDir build --watch", + "format": "eslint . --fix", + "lint": "eslint . && tsc --noEmit" }, "dependencies": { - "@lessstack/prettier-config": "^0.1.6", - "eslint-config-airbnb": "^18.2.1", + "@typescript-eslint/eslint-plugin": "^5.42.1", + "@typescript-eslint/parser": "^5.42.1", "eslint-config-prettier": "^8.3.0", - "eslint-plugin-import": "^2.25.2", - "eslint-plugin-jsx-a11y": "^6.4.1", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-react": "^7.26.1", - "eslint-plugin-react-hooks": "^4.2.0" + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jsx-a11y": "^6.6.1", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-react": "^7.31.10", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-simple-import-sort": "^8.0.0", + "eslint-plugin-sort-destructure-keys": "^1.4.0", + "eslint-plugin-typescript-sort-keys": "^2.1.0" + }, + "devDependencies": { + "@lessstack/prettier-config": "workspace:*", + "@lessstack/typescript-config": "workspace:*", + "eslint": "^8.27.0", + "prettier": "^2.4.1", + "typescript": "^4.7.4" }, "peerDependencies": { "eslint": "^8.2.0", "prettier": "^2.4.1" + }, + "publishConfig": { + "access": "public" } } diff --git a/packages/eslint-config/react.d.ts b/packages/eslint-config/react.d.ts new file mode 100644 index 0000000..d492d84 --- /dev/null +++ b/packages/eslint-config/react.d.ts @@ -0,0 +1 @@ +export * from "./build/react"; diff --git a/packages/eslint-config/react.js b/packages/eslint-config/react.js new file mode 100644 index 0000000..71f9756 --- /dev/null +++ b/packages/eslint-config/react.js @@ -0,0 +1 @@ +module.exports = require("./build/react"); diff --git a/packages/eslint-config/src/base.ts b/packages/eslint-config/src/base.ts new file mode 100644 index 0000000..0427b2a --- /dev/null +++ b/packages/eslint-config/src/base.ts @@ -0,0 +1,239 @@ +import type { ESLint } from "eslint"; + +const baseConfig: ESLint.ConfigData = { + env: { + es2021: true, + }, + extends: ["eslint:recommended", "prettier"], + overrides: [ + { + files: ["webpack.config.[jt]s"], + rules: { + "import/no-extraneous-dependencies": [ + "error", + { + devDependencies: true, + optionalDependencies: false, + }, + ], + }, + }, + ], + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + plugins: [ + "prettier", + "sort-destructure-keys", + "simple-import-sort", + "import", + ], + reportUnusedDisableDirectives: true, + rules: { + "array-callback-return": [ + "error", + { allowImplicit: true, checkForEach: true }, + ], + "block-scoped-var": "error", + camelcase: ["error", { allow: ["__webpack_public_path__"] }], + "class-methods-use-this": "error", + curly: ["error", "all"], + "default-case": "error", + "default-case-last": "error", + "default-param-last": "error", + "dot-notation": "error", + eqeqeq: "error", + "func-name-matching": [ + "error", + { + considerPropertyDescriptor: true, + }, + ], + "func-style": ["error", "expression"], + "grouped-accessor-pairs": ["error", "getBeforeSet"], + "guard-for-in": ["error"], + "import/default": "error", + "import/export": "error", + "import/named": "error", + "import/namespace": "error", + "import/newline-after-import": "error", + "import/no-absolute-path": "error", + "import/no-anonymous-default-export": "error", + "import/no-cycle": "error", + "import/no-deprecated": "error", + "import/no-duplicates": "error", + "import/no-dynamic-require": "error", + "import/no-extraneous-dependencies": [ + "error", + { + devDependencies: false, + optionalDependencies: false, + }, + ], + "import/no-mutable-exports": "error", + "import/no-named-default": "error", + "import/no-namespace": ["error", { ignore: ["*.css"] }], + "import/no-relative-packages": "error", + "import/no-self-import": "error", + "import/no-unresolved": ["error", { caseSensitiveStrict: true }], + "import/no-unused-modules": "error", + "import/no-useless-path-segments": "error", + "import/no-webpack-loader-syntax": "error", + "line-comment-position": ["error", { position: "above" }], + "lines-between-class-members": "error", + "logical-assignment-operators": [ + "error", + "always", + { enforceForIfStatements: true }, + ], + "max-classes-per-file": [ + "error", + { + ignoreExpressions: true, + max: 1, + }, + ], + "no-alert": "error", + "no-array-constructor": "error", + "no-await-in-loop": "error", + "no-caller": "error", + "no-confusing-arrow": "error", + "no-console": ["error", { allow: ["error"] }], + "no-constant-binary-expression": "error", + "no-constructor-return": "error", + "no-continue": "error", + "no-else-return": "error", + "no-empty-function": "error", + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-label": "error", + "no-floating-decimal": "error", + "no-implicit-coercion": ["error", { boolean: false }], + "no-implicit-globals": "error", + "no-implied-eval": "error", + "no-inline-comments": "error", + "no-invalid-this": "error", + "no-iterator": "error", + "no-label-var": "error", + "no-labels": "error", + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-loop-func": "error", + "no-magic-numbers": ["error", { ignore: [1] }], + "no-mixed-operators": "error", + "no-multi-assign": "error", + "no-negated-condition": "error", + "no-nested-ternary": "error", + "no-new": "error", + "no-new-func": "error", + "no-new-object": "error", + "no-new-wrappers": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-plusplus": "error", + "no-promise-executor-return": "error", + "no-proto": "error", + "no-return-assign": "error", + "no-return-await": "error", + "no-script-url": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow": "error", + "no-template-curly-in-string": "error", + "no-throw-literal": "error", + "no-undef-init": "error", + "no-underscore-dangle": ["error", { allow: ["__webpack_public_path__"] }], + "no-unmodified-loop-condition": "error", + "no-unneeded-ternary": "error", + "no-unreachable-loop": "error", + "no-unused-expressions": "error", + "no-unused-private-class-members": "error", + "no-use-before-define": ["error", { variables: false }], + "no-useless-call": "error", + "no-useless-computed-key": "error", + "no-useless-concat": "error", + "no-useless-constructor": "error", + "no-useless-rename": "error", + "no-useless-return": "error", + "no-var": "error", + "no-void": "error", + "object-shorthand": ["error", "properties"], + "operator-assignment": ["error", "always"], + "prefer-arrow-callback": [ + "error", + { + allowNamedFunctions: true, + }, + ], + "prefer-const": [ + "error", + { destructuring: "all", ignoreReadBeforeAssign: true }, + ], + "prefer-destructuring": [ + "error", + { + array: true, + object: true, + }, + { + enforceForRenamedProperties: false, + }, + ], + "prefer-exponentiation-operator": "off", + "prefer-named-capture-group": "off", + "prefer-numeric-literals": "off", + "prefer-object-has-own": "error", + "prefer-object-spread": "error", + "prefer-promise-reject-errors": "error", + "prefer-regex-literals": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + "prettier/prettier": "error", + "quote-props": ["error", "as-needed"], + radix: "error", + "require-atomic-updates": "error", + "require-await": "error", + "simple-import-sort/exports": "error", + "simple-import-sort/imports": [ + "error", + { + groups: [ + // Side effect imports + ["^\\u0000"], + // Node.js builtin imports + ["^node:", "^node:.+\\u0000$"], + // Packages imports + ["^[^./]", "^[^./].+\\u0000$"], + // Relative imports + ["^\\.", "^\\..+\\u0000$"], + // Assets imports + ["\\.(css|svg|jpg|gif|png|eot|svg|ttf|woff|woff2|md|mdx)"], + ], + }, + ], + "sort-destructure-keys/sort-destructure-keys": [ + "error", + { caseSensitive: true }, + ], + "sort-keys": [ + "error", + "asc", + { + allowLineSeparatedGroups: true, + caseSensitive: true, + minKeys: 2, + natural: true, + }, + ], + "sort-vars": "error", + "spaced-comment": ["error", "always"], + "symbol-description": "error", + "vars-on-top": "error", + yoda: ["error", "never"], + }, +}; + +export = baseConfig; diff --git a/packages/eslint-config/src/esm.ts b/packages/eslint-config/src/esm.ts new file mode 100644 index 0000000..876cf81 --- /dev/null +++ b/packages/eslint-config/src/esm.ts @@ -0,0 +1,17 @@ +import type { ESLint } from "eslint"; + +const esmConfig: ESLint.ConfigData = { + env: { + node: true, + }, + rules: { + "import/extensions": [ + "error", + { + js: "ignorePackages", + }, + ], + }, +}; + +export = esmConfig; diff --git a/packages/eslint-config/src/index.ts b/packages/eslint-config/src/index.ts new file mode 100644 index 0000000..029966d --- /dev/null +++ b/packages/eslint-config/src/index.ts @@ -0,0 +1,7 @@ +import type { ESLint } from "eslint"; + +const config: ESLint.ConfigData = { + extends: ["./base", "./jest", "./react", "./typescript"], +}; + +export = config; diff --git a/packages/eslint-config/src/jest.ts b/packages/eslint-config/src/jest.ts new file mode 100644 index 0000000..33b0099 --- /dev/null +++ b/packages/eslint-config/src/jest.ts @@ -0,0 +1,14 @@ +import type { ESLint } from "eslint"; + +const jestConfig: ESLint.ConfigData = { + overrides: [ + { + env: { + jest: true, + }, + files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"], + }, + ], +}; + +export = jestConfig; diff --git a/packages/eslint-config/src/react.ts b/packages/eslint-config/src/react.ts new file mode 100644 index 0000000..89b6511 --- /dev/null +++ b/packages/eslint-config/src/react.ts @@ -0,0 +1,72 @@ +import type { ESLint } from "eslint"; + +const reactConfig: ESLint.ConfigData = { + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + plugins: ["react", "react-hooks"], + rules: { + /** + * Boolean properties must starts with "is" or "has" prefix. + */ + "react/boolean-prop-naming": [ + "error", + { + rule: "^(is|has)[A-Z]([A-Za-z0-9]?)+", + validateNested: false, + }, + ], + /** + * This rule is impracticable as it prohibits dynamic + * assignment even with enforced type or proptype. + * @see: https://github.com/jsx-eslint/eslint-plugin-react/issues/1555 + */ + "react/button-has-type": "off", + /** + * Use destructuring assignment instead of defaultProps they might + * be deprecated in the future. + * @see: https://twitter.com/dan_abramov/status/1133878326358171650 + */ + "react/default-props-match-prop-types": ["error"], + /** + * Destructuring props enforce the use of a single default value by + * destructuring assignment. + * @todo: find rule for enforcing destructuring assignment of optional + * properties. + */ + "react/destructuring-assignment": [ + "error", + "always", + { destructureInSignature: "always" }, + ], + "react/function-component-definition": [ + "error", + { + namedComponents: ["arrow-function"], + }, + ], + "react/require-default-props": [ + "error", + { + functions: "defaultArguments", + }, + ], + "react-hooks/exhaustive-deps": "error", + "react-hooks/rules-of-hooks": "error", + }, + settings: { + react: { + version: "detect", + }, + }, +}; + +export = reactConfig; diff --git a/packages/eslint-config/src/typescript.ts b/packages/eslint-config/src/typescript.ts new file mode 100644 index 0000000..efe3d77 --- /dev/null +++ b/packages/eslint-config/src/typescript.ts @@ -0,0 +1,25 @@ +import type { ESLint } from "eslint"; + +const typescriptConfig: ESLint.ConfigData = { + overrides: [ + { + extends: [ + "plugin:import/typescript", + "plugin:@typescript-eslint/recommended", + ], + files: ["**/*.ts?(x)"], + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint", "typescript-sort-keys"], + rules: { + "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/sort-type-union-intersection-members": "error", + "spaced-comment": ["error", "always", { markers: ["/"] }], + "typescript-sort-keys/interface": "error", + "typescript-sort-keys/string-enum": "error", + }, + }, + ], +}; + +export = typescriptConfig; diff --git a/packages/eslint-config/tsconfig.json b/packages/eslint-config/tsconfig.json new file mode 100644 index 0000000..e958763 --- /dev/null +++ b/packages/eslint-config/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@lessstack/typescript-config/base.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/eslint-config/typescript.d.ts b/packages/eslint-config/typescript.d.ts new file mode 100644 index 0000000..4e4238e --- /dev/null +++ b/packages/eslint-config/typescript.d.ts @@ -0,0 +1 @@ +export * from "./build/typescript"; diff --git a/packages/eslint-config/typescript.js b/packages/eslint-config/typescript.js new file mode 100644 index 0000000..59eb640 --- /dev/null +++ b/packages/eslint-config/typescript.js @@ -0,0 +1 @@ +module.exports = require("./build/typescript"); diff --git a/packages/eslint-config/webpack.js b/packages/eslint-config/webpack.js deleted file mode 100644 index 8932bb8..0000000 --- a/packages/eslint-config/webpack.js +++ /dev/null @@ -1,9 +0,0 @@ -const baseConfig = require("."); - -module.exports = { - ...baseConfig, - env: { - browser: true, - }, - ignorePatterns: ["/build"], -}; diff --git a/packages/express-middleware-web/.eslintrc b/packages/express-middleware-web/.eslintrc deleted file mode 100644 index f0fadbd..0000000 --- a/packages/express-middleware-web/.eslintrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "root": true, - "extends": ["@lessstack"] -} diff --git a/packages/express-middleware-web/.prettierrc b/packages/express-middleware-web/.prettierrc deleted file mode 100644 index 6301ee6..0000000 --- a/packages/express-middleware-web/.prettierrc +++ /dev/null @@ -1 +0,0 @@ -"@lessstack/prettier-config" \ No newline at end of file diff --git a/packages/express-middleware-web/index.js b/packages/express-middleware-web/index.js deleted file mode 100644 index 30f3a86..0000000 --- a/packages/express-middleware-web/index.js +++ /dev/null @@ -1,78 +0,0 @@ -import path from "path"; -import { createRequire } from "module"; -import express, { Router } from "express"; - -const requireCjs = createRequire(import.meta.url); -const knownClients = {}; - -const requireClient = (clientPath) => { - delete requireCjs.cache[clientPath]; - const client = requireCjs(clientPath); - - return client; -}; - -const getClient = (clientPath, publicRoute) => { - let client = knownClients[clientPath].byPublicRoute[publicRoute]; - - if (!client) { - if (knownClients[clientPath].prepared) { - client = knownClients[clientPath].prepared; - knownClients[clientPath].prepared = null; - } else { - client = requireClient(clientPath); - } - - client.setup({ publicRoute }); - knownClients[clientPath].byPublicRoute[publicRoute] = client; - } - - return client; -}; - -const prepareClient = (clientPath) => { - if (!knownClients[clientPath]) { - // we require once in advance to let client throws early - const prepared = requireClient(clientPath); - knownClients[clientPath] = { - prepared, - publicPath: prepared.publicPath, - byPublicRoute: {}, - }; - } - - return { publicPath: knownClients[clientPath].publicPath }; -}; - -const createWebMiddleware = ({ - publicRoute = "/assets", - clientPath, - logger, - getProps, -}) => { - const resolvedClientPath = requireCjs.resolve(clientPath); - const { publicPath } = prepareClient(resolvedClientPath); - - const middleware = new Router(); - - middleware.use(publicRoute, express.static(publicPath)); - middleware.get("*", async (req, res, next) => { - try { - const client = getClient( - resolvedClientPath, - path.join(req.baseUrl, publicRoute), - ); - - await client.render(res, { - props: getProps?.(req, res) || {}, - logger, - }); - } catch (e) { - next(e); - } - }); - - return middleware; -}; - -export default createWebMiddleware; diff --git a/packages/express-middleware-web/package.json b/packages/express-middleware-web/package.json deleted file mode 100644 index 641408c..0000000 --- a/packages/express-middleware-web/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@lessstack/express-middleware-web", - "version": "0.1.6", - "main": "index.js", - "license": "MIT", - "author": "Cahnory (https://github.com/cahnory)", - "repository": "https://github.com/lessstack/lessstack", - "publishConfig": { - "access": "public" - }, - "type": "module", - "scripts": { - "lint": "eslint ." - }, - "devDependencies": { - "@lessstack/eslint-config": "^0.1.6", - "@lessstack/prettier-config": "^0.1.6", - "eslint": "^8.2.0", - "express": "^4.17.1", - "prettier": "^2.4.1" - }, - "peerDependencies": { - "express": "^4.17.1" - } -} diff --git a/packages/jest-config/.eslintrc b/packages/jest-config/.eslintrc deleted file mode 100644 index f0fadbd..0000000 --- a/packages/jest-config/.eslintrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "root": true, - "extends": ["@lessstack"] -} diff --git a/packages/jest-config/.prettierrc b/packages/jest-config/.prettierrc deleted file mode 100644 index 6301ee6..0000000 --- a/packages/jest-config/.prettierrc +++ /dev/null @@ -1 +0,0 @@ -"@lessstack/prettier-config" \ No newline at end of file diff --git a/packages/jest-config/jest-preset.js b/packages/jest-config/jest-preset.js deleted file mode 100644 index b72bed5..0000000 --- a/packages/jest-config/jest-preset.js +++ /dev/null @@ -1,13 +0,0 @@ -const path = require("path"); - -module.exports = { - testEnvironment: "jest-environment-jsdom", - transform: { - "\\.[jt]sx?$": "babel-jest", - "\\.(gif|jpg|png|svg)$": path.join(__dirname, "transformers/base64.js"), - }, - transformIgnorePatterns: [ - "/node_modules/(?!@lessstack/webpack-config)", - "\\.pnp\\.[^\\/]+$", - ], -}; diff --git a/packages/jest-config/package.json b/packages/jest-config/package.json deleted file mode 100644 index 0ba2780..0000000 --- a/packages/jest-config/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@lessstack/jest-config", - "version": "0.1.6", - "main": "jest-preset.js", - "license": "MIT", - "author": "Cahnory (https://github.com/cahnory)", - "repository": "https://github.com/lessstack/lessstack", - "publishConfig": { - "access": "public" - }, - "scripts": { - "lint": "eslint ." - }, - "dependencies": { - "babel-jest": "^27.3.1" - }, - "peerDependencies": { - "eslint": "^8.2.0", - "jest": "^27.3.1", - "prettier": "^2.4.1" - } -} diff --git a/packages/jest-config/transformers/base64.js b/packages/jest-config/transformers/base64.js deleted file mode 100644 index f2a27e6..0000000 --- a/packages/jest-config/transformers/base64.js +++ /dev/null @@ -1,10 +0,0 @@ -const mime = require("../utils/mime"); - -module.exports = { - process: (src, filename) => { - const data = Buffer.from(src).toString("base64"); - const type = mime[filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1]]; - - return `module.exports = ${JSON.stringify(`data:${type};base64,${data}`)}`; - }, -}; diff --git a/packages/jest-config/utils/mime.js b/packages/jest-config/utils/mime.js deleted file mode 100644 index 38e8f9c..0000000 --- a/packages/jest-config/utils/mime.js +++ /dev/null @@ -1,67 +0,0 @@ -module.exports = { - aac: "audio/aac", - abw: "application/x-abiword", - arc: "application/octet-stream", - avi: "video/x-msvideo", - azw: "application/vnd.amazon.ebook", - bin: "application/octet-stream", - bmp: "image/bmp", - bz: "application/x-bzip", - bz2: "application/x-bzip2", - csh: "application/x-csh", - css: "text/css", - csv: "text/csv", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - eot: "application/vnd.ms-fontobject", - epub: "application/epub+zip", - gif: "image/gif", - htm: "text/html", - html: "text/html", - ico: "image/x-icon", - ics: "text/calendar", - jar: "application/java-archive", - jpeg: "image/jpeg", - js: "application/javascript", - json: "application/json", - mid: "audio/midi", - mpeg: "video/mpeg", - mpkg: "application/vnd.apple.installer+xml", - odp: "application/vnd.oasis.opendocument.presentation", - ods: "application/vnd.oasis.opendocument.spreadsheet", - odt: "application/vnd.oasis.opendocument.text", - oga: "audio/ogg", - ogv: "video/ogg", - ogx: "application/ogg", - otf: "font/otf", - png: "image/png", - pdf: "application/pdf", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - rar: "application/x-rar-compressed", - rtf: "application/rtf", - sh: "application/x-sh", - svg: "image/svg+xml", - swf: "application/x-shockwave-flash", - tar: "application/x-tar", - tif: "image/tiff", - tiff: "image/tiff", - ts: "application/typescript", - ttf: "font/ttf", - vsd: "application/vnd.visio", - wav: "audio/x-wav", - weba: "audio/webm", - webm: "video/webm", - webp: "image/webp", - woff: "font/woff", - woff2: "font/woff2", - xhtml: "application/xhtml+xml", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - xml: "application/xml", - xul: "application/vnd.mozilla.xul+xml", - zip: "application/zip", - "3gp": "video/3gpp dans le cas où le conteneur ne comprend pas de vidéo", - "3g2": "video/3gpp2 dans le cas où le conteneur ne comprend pas de vidéo", - "7z": "application/x-7z-compressed", -}; diff --git a/packages/prettier-config/.prettierrc b/packages/prettier-config/.prettierrc index 6301ee6..6b8410b 100644 --- a/packages/prettier-config/.prettierrc +++ b/packages/prettier-config/.prettierrc @@ -1 +1 @@ -"@lessstack/prettier-config" \ No newline at end of file +"@lessstack/prettier-config" diff --git a/packages/prettier-config/package.json b/packages/prettier-config/package.json index 6f6df26..d2e92ee 100644 --- a/packages/prettier-config/package.json +++ b/packages/prettier-config/package.json @@ -8,9 +8,6 @@ "publishConfig": { "access": "public" }, - "scripts": { - "lint": "exit 0" - }, "peerDependencies": { "prettier": "^2.4.1" } diff --git a/packages/react/.eslintignore b/packages/react/.eslintignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/packages/react/.eslintignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/packages/react/.eslintrc b/packages/react/.eslintrc new file mode 100644 index 0000000..0686197 --- /dev/null +++ b/packages/react/.eslintrc @@ -0,0 +1,4 @@ +{ + "root": true, + "extends": ["@lessstack/eslint-config"] +} diff --git a/packages/react/.gitignore b/packages/react/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/packages/react/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/packages/react/.prettierrc b/packages/react/.prettierrc new file mode 100644 index 0000000..6b8410b --- /dev/null +++ b/packages/react/.prettierrc @@ -0,0 +1 @@ +"@lessstack/prettier-config" diff --git a/packages/react/browser.d.ts b/packages/react/browser.d.ts new file mode 100644 index 0000000..6aaa64e --- /dev/null +++ b/packages/react/browser.d.ts @@ -0,0 +1 @@ +export * from "./build/browser"; diff --git a/packages/react/browser.js b/packages/react/browser.js new file mode 100644 index 0000000..aadb782 --- /dev/null +++ b/packages/react/browser.js @@ -0,0 +1,2 @@ +/* eslint-env node */ +module.exports = require("./build/browser"); diff --git a/packages/react/node.d.ts b/packages/react/node.d.ts new file mode 100644 index 0000000..fe39cae --- /dev/null +++ b/packages/react/node.d.ts @@ -0,0 +1 @@ +export * from "./build/node"; diff --git a/packages/react/node.js b/packages/react/node.js new file mode 100644 index 0000000..48debdf --- /dev/null +++ b/packages/react/node.js @@ -0,0 +1,2 @@ +/* eslint-env node */ +module.exports = require("./build/node"); diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..1244177 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,38 @@ +{ + "name": "@lessstack/react", + "version": "0.1.6", + "main": "build/browser.js", + "license": "MIT", + "author": "Cahnory (https://github.com/cahnory)", + "repository": "https://github.com/lessstack/lessstack", + "scripts": { + "build": "tsc --outDir build", + "dev": "tsc --outDir build --watch", + "format": "eslint . --fix", + "lint": "eslint . && tsc --noEmit" + }, + "dependencies": { + "@lessstack/webpack-config": "workspace:*", + "@loadable/component": "^5.15.2", + "@loadable/server": "^5.15.2" + }, + "devDependencies": { + "@lessstack/eslint-config": "workspace:*", + "@lessstack/prettier-config": "workspace:*", + "@lessstack/typescript-config": "workspace:*", + "@types/loadable__component": "^5.13.4", + "@types/loadable__server": "^5.12.6", + "@types/node": "^18.11.9", + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.6", + "eslint": "^8.27.0", + "prettier": "^2.4.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^4.7.4" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/packages/react/src/browser.tsx b/packages/react/src/browser.tsx new file mode 100644 index 0000000..bf79966 --- /dev/null +++ b/packages/react/src/browser.tsx @@ -0,0 +1,37 @@ +/* eslint-env browser */ +import loadable, { loadableReady } from "@loadable/component"; +import { lazy, StrictMode, Suspense } from "react"; +import { hydrateRoot } from "react-dom/client"; +import type { ComponentType } from "react"; + +import Config from "./components/Config"; +import Document from "./components/Document"; +import Links from "./components/ExtractedLinks"; +import Scripts from "./components/ExtractedScripts"; +import Styles from "./components/ExtractedStyles"; +import Root from "./components/Root"; + +export const hydrate = ( + Component: ComponentType, +) => + loadableReady(() => { + const root = document.getElementById(LESSSTACK_RUNTIME_PROPS.rootId); + + if (!root) { + console.error( + `Root element with id "${LESSSTACK_RUNTIME_PROPS.rootId}" not found`, + ); + return; + } + + hydrateRoot( + root, + + + + + , + ); + }); + +export { Config, Document, lazy, Links, loadable, Root, Scripts, Styles }; diff --git a/packages/react/src/components/Config.tsx b/packages/react/src/components/Config.tsx new file mode 100644 index 0000000..87a23b5 --- /dev/null +++ b/packages/react/src/components/Config.tsx @@ -0,0 +1,52 @@ +import { useContext } from "react"; +import type { FC, ReactNode } from "react"; + +import ConfigContext from "../contexts/Config"; +import type { RenderOptions } from "../types"; + +const Config: FC<{ + children?: ReactNode; + doctype?: RenderOptions["doctype"]; + document?: RenderOptions["document"]; + hydratation?: RenderOptions["hydratation"]; + initialProps?: RenderOptions["initialProps"]; + response?: Partial; + rootId?: RenderOptions["rootId"]; + statsPath?: RenderOptions["statsPath"]; +}> = ({ + children = null, + doctype = null, + document = null, + hydratation = null, + initialProps = null, + response = null, + rootId = null, + statsPath = null, +}) => { + const config = useContext(ConfigContext); + + config.doctype = doctype ?? config.doctype; + config.document = document ?? config.document; + config.initialProps = initialProps + ? { ...config.initialProps, ...initialProps } + : config.initialProps; + config.hydratation = hydratation ?? config.hydratation; + config.response = response + ? { + ...config.response, + ...response, + headers: response.headers + ? { + ...config.response.headers, + ...response.headers, + } + : config.response.headers, + } + : config.response; + config.rootId = rootId ?? config.rootId; + config.statsPath = statsPath ?? config.statsPath; + + return <>{children}; +}; + +export default Config; diff --git a/packages/react/src/components/Document.tsx b/packages/react/src/components/Document.tsx new file mode 100644 index 0000000..49a1e53 --- /dev/null +++ b/packages/react/src/components/Document.tsx @@ -0,0 +1,39 @@ +import type { FC, HTMLAttributes } from "react"; + +import ExtractedLinks from "./ExtractedLinks"; +import ExtractedScripts from "./ExtractedScripts"; +import ExtractedStyles from "./ExtractedStyles"; +import Root from "./Root"; + +const Document: FC<{ + bodyProps?: HTMLAttributes; + headProps?: HTMLAttributes; + htmlProps?: HTMLAttributes; + rootProps?: HTMLAttributes; + title?: string; +}> = ({ + bodyProps = {}, + headProps = {}, + htmlProps = {}, + rootProps = {}, + title = null, +} = {}) => ( + + + + + {title ?? "Lessstack App"} + + + + + + + + +); + +export default Document; diff --git a/packages/react/src/components/ExtractedLinks.tsx b/packages/react/src/components/ExtractedLinks.tsx new file mode 100644 index 0000000..04baa42 --- /dev/null +++ b/packages/react/src/components/ExtractedLinks.tsx @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import type { FC } from "react"; + +import RenderContext from "../contexts/Render"; + +const ExtractedLinks: FC = () => { + const { collector, extraction } = useContext(RenderContext); + collector.linksAdded = true; + return <>{extraction.linkElements}; +}; + +export default ExtractedLinks; diff --git a/packages/react/src/components/ExtractedScripts.tsx b/packages/react/src/components/ExtractedScripts.tsx new file mode 100644 index 0000000..22c1c6c --- /dev/null +++ b/packages/react/src/components/ExtractedScripts.tsx @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import type { FC } from "react"; + +import RenderContext from "../contexts/Render"; + +const ExtractedScripts: FC = () => { + const { collector, extraction } = useContext(RenderContext); + collector.scriptsAdded = true; + return <>{extraction.scriptElements}; +}; + +export default ExtractedScripts; diff --git a/packages/react/src/components/ExtractedStyles.tsx b/packages/react/src/components/ExtractedStyles.tsx new file mode 100644 index 0000000..7834e0f --- /dev/null +++ b/packages/react/src/components/ExtractedStyles.tsx @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import type { FC } from "react"; + +import RenderContext from "../contexts/Render"; + +const ExtractedStyles: FC = () => { + const { collector, extraction } = useContext(RenderContext); + collector.stylesAdded = true; + return <>{extraction.styleElements}; +}; + +export default ExtractedStyles; diff --git a/packages/react/src/components/NodeWrapper.tsx b/packages/react/src/components/NodeWrapper.tsx new file mode 100644 index 0000000..1798392 --- /dev/null +++ b/packages/react/src/components/NodeWrapper.tsx @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import type { FC, ReactElement } from "react"; + +import RenderContext from "../contexts/Render"; +import type { RenderContextProps } from "../contexts/Render"; +import type { RenderCollector } from "../renderCollector"; +import type { RenderExtraction } from "../types"; + +const NodeWrapper: FC<{ + children: ReactElement; + collector: RenderCollector; + extraction: RenderExtraction; + rootHtml: string; + rootId: string; +}> = ({ children, collector, extraction, rootHtml, rootId }) => { + const RenderContextValue = useMemo( + () => ({ + collector, + extraction, + rootHtml, + rootId, + }), + [collector, extraction, rootHtml, rootId], + ); + + return ( + + {children} + + ); +}; + +export default NodeWrapper; diff --git a/packages/react/src/components/Root.tsx b/packages/react/src/components/Root.tsx new file mode 100644 index 0000000..3720c20 --- /dev/null +++ b/packages/react/src/components/Root.tsx @@ -0,0 +1,21 @@ +import { useContext } from "react"; +import type { FC, JSXElementConstructor } from "react"; + +import RenderContext from "../contexts/Render"; + +const Root: FC<{ + as?: JSXElementConstructor> | string; +}> = ({ as: Component = "div", ...props }) => { + const { collector, rootHtml, rootId } = useContext(RenderContext); + collector.rootAdded = true; + + return ( + + ); +}; + +export default Root; diff --git a/packages/react/src/contexts/Config.ts b/packages/react/src/contexts/Config.ts new file mode 100644 index 0000000..bb81332 --- /dev/null +++ b/packages/react/src/contexts/Config.ts @@ -0,0 +1,20 @@ +import { createContext } from "react"; + +import Document from "../components/Document"; +import type { RenderOptions } from "../types"; + +const ConfigContext = createContext({ + doctype: "", + document: Document, + hydratation: "all", + initialProps: {}, + response: { + headers: {} as RenderOptions["response"]["headers"], + statusCode: 200, + statusMessage: "", + }, + rootId: "root", + statsPath: "", +}); + +export default ConfigContext; diff --git a/packages/react/src/contexts/Render.ts b/packages/react/src/contexts/Render.ts new file mode 100644 index 0000000..e8cff40 --- /dev/null +++ b/packages/react/src/contexts/Render.ts @@ -0,0 +1,29 @@ +import { createContext } from "react"; + +import type { RenderCollector } from "../renderCollector"; +import type { RenderExtraction, RenderOptions } from "../types"; + +export type RenderContextProps = { + collector: RenderCollector; + extraction: RenderExtraction; + rootHtml: string; + rootId: RenderOptions["rootId"]; +}; + +const RenderContext = createContext({ + collector: { + linksAdded: false, + rootAdded: false, + scriptsAdded: false, + stylesAdded: false, + }, + extraction: { + linkElements: [], + scriptElements: [], + styleElements: [], + }, + rootHtml: "", + rootId: "root", +}); + +export default RenderContext; diff --git a/packages/react/src/node.tsx b/packages/react/src/node.tsx new file mode 100644 index 0000000..8b5068e --- /dev/null +++ b/packages/react/src/node.tsx @@ -0,0 +1,38 @@ +import path from "path"; +import type { ComponentType } from "react"; + +import { renderToStream } from "./renderer/stream"; +import type { RenderToStreamOptions } from "./renderer/stream"; + +export const statsPath = path.resolve( + __dirname, + LESSSTACK_BUILD_PROPS.statsPath, +); + +export const publicPath = path.resolve( + __dirname, + LESSSTACK_BUILD_PROPS.publicPath, +); + +export const createEntry = ( + Component: ComponentType, +) => + ({ + publicPath, + streamRendering: ({ + initialProps, + logger, + response, + }: Pick< + RenderToStreamOptions, + "initialProps" | "logger" | "response" + >) => + renderToStream({ + component: Component, + initialProps, + logger, + publicPath: LESSSTACK_RUNTIME_PROPS.webpackPublicPath, + response, + statsPath, + } as RenderToStreamOptions), + } as const); diff --git a/packages/react/src/renderCollector.ts b/packages/react/src/renderCollector.ts new file mode 100644 index 0000000..b2c5944 --- /dev/null +++ b/packages/react/src/renderCollector.ts @@ -0,0 +1,38 @@ +export type RenderCollector = { + linksAdded: boolean; + rootAdded: boolean; + scriptsAdded: boolean; + stylesAdded: boolean; +}; + +export type RenderCollectorLogger = { + error: (...obj: unknown[]) => void; + log: (...obj: unknown[]) => void; + warn: (...obj: unknown[]) => void; +}; + +export const defaultLogger: RenderCollectorLogger | null = + (process.env.NODE_ENV ?? "development") === "development" ? console : null; + +export const validateCollector = ( + collector: RenderCollector, + logger: RenderCollectorLogger | null = defaultLogger, +) => { + if (logger !== null) { + if (!collector.scriptsAdded) { + logger.warn(`Custom Document does not contain `); + } + + if (!collector.linksAdded) { + logger.warn(`Custom? Document does not contain `); + } + + if (!collector.stylesAdded) { + logger.warn(`Custom Document does not contain `); + } + } + + if (!collector.rootAdded) { + throw new Error(`Custom Document does not contain `); + } +}; diff --git a/packages/react/src/renderer/stream.tsx b/packages/react/src/renderer/stream.tsx new file mode 100644 index 0000000..5e0acc2 --- /dev/null +++ b/packages/react/src/renderer/stream.tsx @@ -0,0 +1,218 @@ +import { ChunkExtractor } from "@loadable/server"; +import { StrictMode, Suspense } from "react"; +import { renderToPipeableStream, renderToStaticMarkup } from "react-dom/server"; +import { PassThrough } from "stream"; +import type { ServerResponse } from "http"; +import type { ReactElement } from "react"; +import type { Writable } from "stream"; + +import DefaultDocument from "../components/Document"; +import NodeWrapper from "../components/NodeWrapper"; +import ConfigContext from "../contexts/Config"; +import { validateCollector } from "../renderCollector"; +import type { + RenderCollector, + RenderCollectorLogger, +} from "../renderCollector"; +import type { + LessstackRuntimeProps, + RendererBaseOptions, + RenderExtraction, + RenderOptions, +} from "../types"; + +const HTTP_REDIRECTION_STATUS_CODES = { + found: 302, + movedPermanently: 301, + multipleChoices: 300, + notModified: 304, + permanentRedirect: 308, + seeOther: 303, + temporaryRedirect: 307, +} as const; + +export const HTTP_REDIRECTION_STATUS_CODES_LIST = Object.values( + HTTP_REDIRECTION_STATUS_CODES, +); + +export type RenderToStreamResponse = ServerResponse | Writable; + +export type RenderToStreamOptions = + RendererBaseOptions & { + logger?: RenderCollectorLogger; + publicPath: string; + response: RenderToStreamResponse; + statsPath: string; + }; + +export const renderToStream = ({ + component: Component, + initialProps = {} as Props, + logger, + publicPath, + response, + statsPath, +}: RenderToStreamOptions) => { + const extractor = new ChunkExtractor({ + publicPath, + statsFile: statsPath, + }); + + const config: RenderOptions = { + doctype: "", + document: DefaultDocument, + hydratation: "all", + initialProps: {}, + response: { + headers: {}, + statusCode: 200, + statusMessage: "", + }, + rootId: "root", + statsPath, + }; + + const { pipe } = renderToPipeableStream( + extractor.collectChunks( + + + + + , + ), + { + onAllReady: () => { + if (config.hydratation === "selective") { + return; + } + + startRenderOutput({ + config, + extractor, + logger, + pipe, + response, + }); + }, + onError(error) { + logger?.error?.(error); + }, + onShellError(error) { + logger?.error?.(error); + response.end(); + }, + onShellReady() { + if (config.hydratation !== "selective") { + return; + } + + startRenderOutput({ + config, + extractor, + logger, + pipe, + response, + }); + }, + }, + ); +}; + +const startRenderOutput = ({ + config, + extractor, + logger, + pipe, + response, +}: { + config: RenderOptions; + extractor: ChunkExtractor; + logger?: RenderCollectorLogger; + pipe: (stream: Writable) => void; + response: ServerResponse | Writable; +}) => { + if ("writeHead" in response) { + if (config.response.headers.location) { + response.writeHead( + (HTTP_REDIRECTION_STATUS_CODES_LIST as readonly number[]).includes( + config.response.statusCode, + ) + ? config.response.statusCode + : HTTP_REDIRECTION_STATUS_CODES.permanentRedirect, + config.response.statusMessage, + config.response.headers, + ); + return; + } + + response.writeHead( + config.response.statusCode, + config.response.statusMessage, + config.response.headers, + ); + } + + const { document: Document, rootId } = config; + const collector: RenderCollector = { + linksAdded: false, + rootAdded: false, + scriptsAdded: false, + stylesAdded: false, + }; + const [beforeRender, afterRender] = renderToStaticMarkup( + + >>><<<<"} + rootId={rootId} + > + + + , + ).split(">>>><<<<"); + + validateCollector(collector, logger); + + const pass = new PassThrough(); + response.write(config.doctype); + response.write(beforeRender); + pass.on("data", (chunk) => response.write(chunk)); + pass.on("close", () => { + response.write(afterRender); + response.end(); + }); + return pipe(pass); +}; + +const filterHmrElements = (elements: ReactElement[]) => + elements.filter( + (element) => + !(element.props.src || element.props.href)?.match(/-wps-hmr\.[^.\\/]+$/), + ); + +const extract = ( + extractor: ChunkExtractor, + config: RenderOptions, +): RenderExtraction => ({ + linkElements: filterHmrElements( + extractor.getLinkElements(), + ) as RenderExtraction["linkElements"], + scriptElements: [ +