diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index f345e00f..30df7a2e 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [12.20.0, 14.x] + node-version: [14.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/client/index.js b/client/index.js index 887bd72c..7d487f50 100644 --- a/client/index.js +++ b/client/index.js @@ -17,7 +17,6 @@ import hydrate from './hydrate'; import router from './router'; import settings from './settings'; import worker from './worker'; -import klassMap from './klassMap'; import windowEvent from './windowEvent' context.page = page; @@ -42,7 +41,6 @@ export default class Nullstack { static invoke = invoke; static fragment = fragment; static use = useClientPlugins; - static klassMap = {} static context = generateContext({}) static start(Starter) { @@ -108,29 +106,26 @@ export default class Nullstack { } if (module.hot) { + Nullstack.serverHashes ??= {} const socket = new WebSocket('ws' + router.base.slice(4) + '/ws'); - window.lastHash socket.onmessage = async function (e) { const data = JSON.parse(e.data) - if (data.type === 'NULLSTACK_SERVER_STARTED') { - (window.needsReload || !environment.hot) && window.location.reload() - } else if (data.type === 'hash') { - const newHash = data.data.slice(20) - if (newHash === window.lastHash) { - window.needsReload = true - } else { - window.lastHash = newHash - } + if (data.type === 'NULLSTACK_SERVER_STARTED' && (Nullstack.needsReload || !environment.hot)) { + window.location.reload() } }; - Nullstack.updateInstancesPrototypes = function updateInstancesPrototypes(hash, klass) { + Nullstack.updateInstancesPrototypes = function updateInstancesPrototypes(klass, hash, serverHashes) { for (const key in context.instances) { const instance = context.instances[key] if (instance.constructor.hash === hash) { Object.setPrototypeOf(instance, klass.prototype); } } - klassMap[hash] = klass + if (Nullstack.serverHashes[hash] && Nullstack.serverHashes[hash] !== serverHashes) { + Nullstack.needsReload = true + } + Nullstack.serverHashes[hash] = serverHashes + client.update() } Nullstack.hotReload = function hotReload(klass) { if (client.skipHotReplacement) { diff --git a/client/klassMap.js b/client/klassMap.js deleted file mode 100644 index 064689cb..00000000 --- a/client/klassMap.js +++ /dev/null @@ -1,3 +0,0 @@ -const klassMap = {} - -export default klassMap \ No newline at end of file diff --git a/loaders/inject-hmr.js b/loaders/inject-hmr.js index 8d325d03..28fb5af8 100644 --- a/loaders/inject-hmr.js +++ b/loaders/inject-hmr.js @@ -26,11 +26,11 @@ module.exports = function (source) { return source + ` if (module.hot) { - if (window.needsClientReload) { + if (Nullstack.needsClientReload) { window.location.reload() } module.hot.accept() - window.needsClientReload = true + Nullstack.needsClientReload = true module.hot.accept('${klassPath}', () => { Nullstack.hotReload(${klassName}) }) diff --git a/loaders/remove-import-from-client.js b/loaders/remove-import-from-client.js index a206feed..d9e2e3ab 100644 --- a/loaders/remove-import-from-client.js +++ b/loaders/remove-import-from-client.js @@ -1,29 +1,33 @@ const parse = require('@babel/parser').parse; const traverse = require("@babel/traverse").default; -module.exports = function(source) { +const parentTypes = ['ImportDefaultSpecifier', 'ImportSpecifier', 'ImportNamespaceSpecifier'] + +module.exports = function (source) { const ast = parse(source, { sourceType: 'module', plugins: ['classProperties', 'jsx'] }); const imports = {}; function findImports(path) { - if(path.node.local.name !== 'Nullstack') { + if (path.node.local.name !== 'Nullstack') { const parent = path.findParent((path) => path.isImportDeclaration()); const start = parent.node.loc.start.line; const end = parent.node.loc.end.line; const lines = new Array(end - start + 1).fill().map((d, i) => i + start); const key = lines.join('.'); - imports[path.node.local.name] = {lines, key}; + imports[path.node.local.name] = { lines, key }; } } function findIdentifiers(path) { - if(path.parent.type !== 'ImportDefaultSpecifier' && path.parent.type !== 'ImportSpecifier') { + if (parentTypes.indexOf(path.parent.type) === -1) { const target = imports[path.node.name]; - if(target) { - for(const name in imports) { - if(imports[name].key === target.key) { - delete imports[name]; + if (target) { + for (const name in imports) { + if (imports[name].key === target.key) { + if (path.parent.type !== 'MemberExpression' || path.parent.object?.type !== 'ThisExpression') { + delete imports[name]; + } } } } @@ -31,7 +35,8 @@ module.exports = function(source) { } traverse(ast, { ImportSpecifier: findImports, - ImportDefaultSpecifier: findImports + ImportDefaultSpecifier: findImports, + ImportNamespaceSpecifier: findImports }); traverse(ast, { Identifier: findIdentifiers, diff --git a/loaders/remove-static-from-client.js b/loaders/remove-static-from-client.js index ea215e2c..9a310dc2 100644 --- a/loaders/remove-static-from-client.js +++ b/loaders/remove-static-from-client.js @@ -5,6 +5,7 @@ const traverse = require("@babel/traverse").default; module.exports = function removeStaticFromClient(source) { const id = this.resourcePath.replace(this.rootContext, '') const hash = crypto.createHash('md5').update(id).digest("hex"); + let serverSource = '' let hashPosition; let klassName; const injections = {}; @@ -25,6 +26,7 @@ module.exports = function removeStaticFromClient(source) { ClassMethod(path) { if (path.node.static && path.node.async) { injections[path.node.start] = { end: path.node.end, name: path.node.key.name }; + serverSource += source.slice(path.node.start, path.node.end) if (!positions.includes(path.node.start)) { positions.push(path.node.start); } @@ -56,7 +58,8 @@ module.exports = function removeStaticFromClient(source) { } let newSource = outputs.reverse().join('') if (klassName) { - newSource += `\nif (module.hot) { Nullstack.updateInstancesPrototypes(${klassName}.hash, ${klassName}) }`; + const serverHash = crypto.createHash('md5').update(serverSource).digest("hex"); + newSource += `\nif (module.hot) { module.hot.accept(); Nullstack.updateInstancesPrototypes(${klassName}, ${klassName}.hash, '${serverHash}') }`; } return newSource } \ No newline at end of file diff --git a/package.json b/package.json index a3eae620..fd08a1e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nullstack", - "version": "0.16.2", + "version": "0.16.3", "description": "Full-stack Javascript Components for one-dev armies", "main": "nullstack.js", "author": "Mortaro", @@ -12,9 +12,17 @@ }, "types": "./types/index.d.ts", "dependencies": { + "@babel/core": "^7.18.13", "@babel/parser": "7.17.12", + "@babel/preset-env": "^7.18.10", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-export-default-from": "^7.18.10", + "@babel/plugin-transform-react-jsx": "^7.18.10", + "@babel/plugin-transform-typescript": "^7.18.12", + "@babel/preset-react": "^7.18.6", "@babel/traverse": "7.17.12", "@swc/core": "1.2.179", + "babel-loader": "^8.2.5", "body-parser": "1.20.0", "commander": "8.3.0", "copy-webpack-plugin": "^11.0.0", diff --git a/scripts/index.js b/scripts/index.js index 34034d1f..e699ca24 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -4,7 +4,7 @@ const { version } = require('../package.json'); const webpack = require('webpack'); const path = require('path'); -const { existsSync, rmdirSync, readdir, unlink } = require('fs'); +const { existsSync, readdir, unlink } = require('fs'); const customConfig = path.resolve(process.cwd(), './webpack.config.js'); const config = existsSync(customConfig) ? require(customConfig) : require('../webpack.config'); const dotenv = require('dotenv') @@ -61,7 +61,7 @@ function clearOutput(outputPath) { }); } -async function start({ input, port, env, mode = 'spa', cold, disk }) { +async function start({ input, port, env, mode = 'spa', cold, disk, loader = 'swc' }) { const environment = 'development' console.log(` 🚀️ Starting your application in ${environment} mode...`); loadEnv(env) @@ -85,7 +85,7 @@ async function start({ input, port, env, mode = 'spa', cold, disk }) { host: process.env['NULLSTACK_PROJECT_DOMAIN'], devMiddleware: { index: false, - stats: 'none', + stats: 'errors-only', writeToDisk, }, client: { @@ -135,7 +135,7 @@ async function start({ input, port, env, mode = 'spa', cold, disk }) { webSocketServer: require.resolve('./socket'), port: process.env['NULLSTACK_SERVER_PORT'] }; - const compiler = getCompiler({ environment, input, disk }); + const compiler = getCompiler({ environment, input, disk, loader }); clearOutput(compiler.compilers[0].outputPath) const server = new WebpackDevServer(devServerOptions, compiler); const portChecker = require('express')().listen(process.env['NULLSTACK_SERVER_PORT'], () => { @@ -173,6 +173,7 @@ program .option('-e, --env ', 'Name of the environment file that should be loaded') .option('-d, --disk', 'Write files to disk') .option('-c, --cold', 'Disable hot module replacement') + .addOption(new program.Option('-l, --loader ', 'Use Babel or SWC loader').choices(['swc', 'babel'])) .helpOption('-h, --help', 'Learn more about this command') .action(start) diff --git a/tests/src/Application.njs b/tests/src/Application.njs index af60504b..fd6afdec 100644 --- a/tests/src/Application.njs +++ b/tests/src/Application.njs @@ -53,6 +53,7 @@ import TextObserver from './TextObserver'; import BodyFragment from './BodyFragment'; import ArrayAttributes from './ArrayAttributes'; import RouteScroll from './RouteScroll'; +import IsomorphicImport from './IsomorphicImport.njs'; class Application extends Nullstack { @@ -133,6 +134,7 @@ class Application extends Nullstack { + ) diff --git a/tests/src/IsomorphicImport.njs b/tests/src/IsomorphicImport.njs new file mode 100644 index 00000000..dbfdd035 --- /dev/null +++ b/tests/src/IsomorphicImport.njs @@ -0,0 +1,40 @@ +import Nullstack from 'nullstack'; +import { clientOnly } from './helpers'; +import { serverOnly } from './helpers'; +import { serverOnly as serverOnlyAlias } from './helpers'; +import { clientOnly as clientOnlyAlias } from './helpers'; +import * as namespacedImport from './helpers'; + +class IsomorphicImport extends Nullstack { + + static async serverFunction() { + return { + serverOnly: serverOnly(), + serverOnlyAlias: serverOnlyAlias(), + namespacedServerOnly: namespacedImport.serverOnly(), + } + } + + async initiate() { + const data = await this.serverFunction() + Object.assign(this, data) + this.clientOnly = clientOnly() + this.clientOnlyAlias = clientOnlyAlias() + } + + render() { + return ( +
+ ) + } + +} + +export default IsomorphicImport; \ No newline at end of file diff --git a/tests/src/IsomorphicImport.test.js b/tests/src/IsomorphicImport.test.js new file mode 100644 index 00000000..17043dbe --- /dev/null +++ b/tests/src/IsomorphicImport.test.js @@ -0,0 +1,33 @@ +describe('IsomorphicImport', () => { + + beforeEach(async () => { + await page.goto('http://localhost:6969/isomorphic-import'); + await page.waitForSelector('[data-hydrated]'); + }); + + test('isomorphic imports used in the client stay in the client bundle', async () => { + const element = await page.$('[data-client-only]'); + expect(element).toBeTruthy(); + }); + + test('isomorphic aliased imports used in the client stay in the client bundle', async () => { + const element = await page.$('[data-client-only-alias]'); + expect(element).toBeTruthy(); + }); + + test('isomorphic imports used in the server only are removed from the client bundle', async () => { + const element = await page.$('[data-server-only]'); + expect(element).toBeTruthy(); + }); + + test('isomorphic aliased imports used in the server only are removed from the client bundle', async () => { + const element = await page.$('[data-server-only-alias]'); + expect(element).toBeTruthy(); + }); + + test('isomorphic namespaced imports used in the server only are removed from the client bundle', async () => { + const element = await page.$('[data-namespaced-server-only]'); + expect(element).toBeTruthy(); + }); + +}); \ No newline at end of file diff --git a/tests/src/ServerFunctions.njs b/tests/src/ServerFunctions.njs index 325817a5..3c4872a1 100644 --- a/tests/src/ServerFunctions.njs +++ b/tests/src/ServerFunctions.njs @@ -1,7 +1,6 @@ // server function works import { readFileSync } from 'fs'; import Nullstack from 'nullstack'; -import { clientOnly, serverOnly } from './helpers'; const decodedString = "! * ' ( ) ; : @ & = + $ , / ? % # [ ]" @@ -40,7 +39,6 @@ class ServerFunctions extends Nullstack { } static async useNodeFileSystem() { - serverOnly(); const text = readFileSync('src/ServerFunctions.njs', 'utf-8'); return text.split(`\n`)[0].trim(); } @@ -76,7 +74,6 @@ class ServerFunctions extends Nullstack { async hydrate() { this.underlineRemovedFromClient = !ServerFunctions._privateFunction; - this.clientOnly = clientOnly(); this.doublePlusOneClient = await ServerFunctions.getDoublePlusOne({ number: 34 }) this.acceptsSpecialCharacters = await this.getEncodedString({ string: decodedString }) this.hydratedOriginalUrl = await ServerFunctions.getRequestUrl() @@ -92,7 +89,6 @@ class ServerFunctions extends Nullstack {
-
diff --git a/tests/src/ServerFunctions.test.js b/tests/src/ServerFunctions.test.js index 4e40f30c..326ee6da 100644 --- a/tests/src/ServerFunctions.test.js +++ b/tests/src/ServerFunctions.test.js @@ -41,11 +41,6 @@ describe('ServerFunctions', () => { expect(element).toBeTruthy(); }); - test('isomorphic imports stay in the client', async () => { - const element = await page.$('[data-client-only]'); - expect(element).toBeTruthy(); - }); - test('server functions can be invoked from the constructor constant on server', async () => { await page.waitForSelector('[data-double-plus-one-server]'); const element = await page.$('[data-double-plus-one-server]'); diff --git a/types/ClientContext.d.ts b/types/ClientContext.d.ts index dc0a32c3..6a343c5e 100644 --- a/types/ClientContext.d.ts +++ b/types/ClientContext.d.ts @@ -14,7 +14,7 @@ export type NullstackClientContext = TProps & { /** * Callback function that bootstrap the context for the application. */ - start?: () => void; + start?: () => Promise; /** * Information about the document `head` metatags. diff --git a/types/ServerContext.d.ts b/types/ServerContext.d.ts index 58b9fd2a..f8a51fd8 100644 --- a/types/ServerContext.d.ts +++ b/types/ServerContext.d.ts @@ -12,7 +12,7 @@ export type NullstackServerContext = TProps & { /** * Callback function that bootstrap the context for the application. */ - start?: () => void; + start?: () => Promise; /** * Information about the app manifest and some metatags. diff --git a/webpack.config.js b/webpack.config.js index 6349cd61..2e16600b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -56,6 +56,25 @@ const swcJs = { } }; +const babelJs = { + test: /\.js$/, + resolve: { + extensions: ['.njs', '.js', '.nts', '.ts', '.jsx', '.tsx'] + }, + use: { + loader: require.resolve('babel-loader'), + options: { + "presets": [ + ["@babel/preset-env", { "targets": { node: "10" } }] + ], + "plugins": [ + "@babel/plugin-proposal-export-default-from", + "@babel/plugin-proposal-class-properties" + ] + } + } +}; + const swcTs = { test: /\.ts$/, use: { @@ -74,7 +93,26 @@ const swcTs = { } }; -const nullstackJavascript = { +const babelTs = { + test: /\.ts$/, + resolve: { + extensions: ['.njs', '.js', '.nts', '.ts', '.jsx', '.tsx'] + }, + use: { + loader: require.resolve('babel-loader'), + options: { + "presets": [ + ["@babel/preset-env", { "targets": { node: "10" } }], + "@babel/preset-react", + ], + "plugins": [ + "@babel/plugin-transform-typescript", + ] + } + } +}; + +const swcNullstackJavascript = { test: /\.(njs|nts|jsx|tsx)$/, use: { loader: require.resolve('swc-loader'), @@ -100,7 +138,32 @@ const nullstackJavascript = { } }; -const nullstackTypescript = { +const babelNullstackJavascript = { + test: /\.(njs|jsx)$/, + resolve: { + extensions: ['.njs', '.js', '.nts', '.ts', '.jsx', '.tsx'] + }, + use: { + loader: require.resolve('babel-loader'), + options: { + "presets": [ + ["@babel/preset-env", { "targets": { node: "10" } }], + "@babel/preset-react", + ], + "plugins": [ + "@babel/plugin-proposal-export-default-from", + "@babel/plugin-proposal-class-properties", + ["@babel/plugin-transform-react-jsx", { + "pragma": "Nullstack.element", + "pragmaFrag": "Nullstack.fragment", + "throwIfNamespace": false + }] + ] + } + } +}; + +const swcNullstackTypescript = { test: /\.(nts|tsx)$/, use: { loader: require.resolve('swc-loader'), @@ -126,11 +189,41 @@ const nullstackTypescript = { } }; +const babelNullstackTypescript = { + test: /\.(nts|tsx)$/, + resolve: { + extensions: ['.njs', '.js', '.nts', '.ts', '.jsx', '.tsx'] + }, + use: { + loader: require.resolve('babel-loader'), + options: { + "presets": [ + ["@babel/preset-env", { "targets": { node: "10" } }], + "@babel/preset-react", + ], + "plugins": [ + ["@babel/plugin-transform-typescript", { + isTSX: true, + allExtensions: true, + tsxPragma: "Nullstack.element", + tsxPragmaFrag: "Nullstack.fragment" + }], + ["@babel/plugin-transform-react-jsx", { + "pragma": "Nullstack.element", + "pragmaFrag": "Nullstack.fragment", + "throwIfNamespace": false + }] + ] + } + } +}; + function server(env, argv) { const dir = argv.input ? path.join(__dirname, argv.input) : process.cwd(); const entryExtension = existsSync(path.join(dir, 'server.ts')) ? 'ts' : 'js'; const icons = {}; const publicFiles = readdirSync(path.join(dir, 'public')); + const babel = argv.babel; for (const file of publicFiles) { if (file.startsWith('icon-')) { const size = file.split('x')[1].split('.')[0]; @@ -216,9 +309,9 @@ function server(env, argv) { ] } }, - swcJs, - swcTs, - nullstackJavascript, + babel ? babelJs : swcJs, + babel ? babelTs : swcTs, + babel ? babelNullstackJavascript : swcNullstackJavascript, { test: /\.(njs|nts|jsx|tsx)$/, loader: getLoader('inject-nullstack.js'), @@ -237,7 +330,7 @@ function server(env, argv) { test: /\.(njs|nts|jsx|tsx)$/, loader: getLoader('register-inner-components.js'), }, - nullstackTypescript, + babel ? babelNullstackTypescript : swcNullstackTypescript, { test: /\.(njs|nts|jsx|tsx)$/, loader: getLoader('add-source-to-node.js'), @@ -271,6 +364,7 @@ function client(env, argv) { const folder = isDev ? '.development' : '.production'; const devtool = isDev ? 'inline-cheap-module-source-map' : false; const minimize = !isDev; + const babel = argv.loader === 'babel'; const plugins = [ new MiniCssExtractPlugin({ filename: "client.css", @@ -312,7 +406,7 @@ function client(env, argv) { module: { rules: [ { - test: /client.js$/, + test: /client\.(js|ts)$/, loader: getLoader('inject-hmr.js'), }, { @@ -327,9 +421,9 @@ function client(env, argv) { ] } }, - swcJs, - swcTs, - nullstackJavascript, + babel ? babelJs : swcJs, + babel ? babelTs : swcTs, + babel ? babelNullstackJavascript : swcNullstackJavascript, { test: /\.(njs|nts|jsx|tsx)$/, loader: getLoader('remove-import-from-client.js'), @@ -354,7 +448,7 @@ function client(env, argv) { test: /\.(njs|nts|jsx|tsx)$/, loader: getLoader('register-inner-components.js'), }, - nullstackTypescript, + babel ? babelNullstackTypescript : swcNullstackTypescript, { test: /\.(njs|nts|jsx|tsx)$/, loader: getLoader('add-source-to-node.js'),