diff --git a/builders/logger.js b/builders/logger.js new file mode 100644 index 00000000..d85cf003 --- /dev/null +++ b/builders/logger.js @@ -0,0 +1,43 @@ + +const clocks = ['πŸ•›', 'πŸ•', 'πŸ•‘', 'πŸ•’', 'πŸ•“', 'πŸ•”', 'πŸ••', 'πŸ•–', 'πŸ•—', 'πŸ•˜', 'πŸ•™', 'πŸ•š'] + +function logger(bundle, mode) { + let cindex = 0 + let timer = null + let stoped = false + + function dots() { + return Array((cindex % 3) + 1).fill('.').join('') + } + + function reset() { + if (process.stdout.clearLine) { + process.stdout.clearLine() + process.stdout.cursorTo(0) + } + } + + function start() { + reset() + if (cindex >= clocks.length) { + cindex = 0 + } + let symbol = clocks[cindex] + process.stdout.write(` ${symbol} Building your ${bundle} in ${mode} mode ${dots()}`) + cindex++; + timer = setTimeout(start, 200) + } + start() + + function stop() { + if (stoped) return + clearTimeout(timer) + reset() + process.stdout.write(` βœ… Starting your ${bundle} in ${mode} mode\n`) + stoped = true + } + + return { stop } +} + +module.exports = logger \ No newline at end of file diff --git a/builders/spa.js b/builders/spa.js index c7eeb28b..ccdeebb2 100644 --- a/builders/spa.js +++ b/builders/spa.js @@ -34,7 +34,7 @@ module.exports = async function spa({ output, cache, environment }) { await copy('/robots.txt') console.info() - console.info('\x1b[36m%s\x1b[0m', ` βœ…οΈ ${projectName} is ready at ${folder}\n`) + console.info('\x1b[36m%s\x1b[0m', ` πŸš€ ${projectName} is ready at ${folder}\n`) if (cache) { console.info('Storing cache...') diff --git a/builders/ssg.js b/builders/ssg.js index bd13fdbc..c629ff39 100644 --- a/builders/ssg.js +++ b/builders/ssg.js @@ -79,9 +79,8 @@ module.exports = async function ssg({ output, cache, environment }) { const urls = Object.keys(pages).map((p) => { const page = pages[p] const canonical = `https://${application.project.domain}${p}` - return `${canonical}${timestamp}${ - page.changes ? `${page.changes}` : '' - }${page.priority ? `${page.priority.toFixed(1)}` : ''}` + return `${canonical}${timestamp}${page.changes ? `${page.changes}` : '' + }${page.priority ? `${page.priority.toFixed(1)}` : ''}` }) const xml = `${urls.join( '', @@ -111,7 +110,7 @@ module.exports = async function ssg({ output, cache, environment }) { await createSitemap() console.info() - console.info('\x1b[36m%s\x1b[0m', ` βœ…οΈ ${projectName} is ready at ${folder}\n`) + console.info('\x1b[36m%s\x1b[0m', ` πŸš€ ${projectName} is ready at ${folder}\n`) if (cache) { console.info('Storing cache...') diff --git a/builders/ssr.js b/builders/ssr.js index eb3ead35..b72bcaac 100644 --- a/builders/ssr.js +++ b/builders/ssr.js @@ -3,7 +3,7 @@ module.exports = async function ssr({ cache }) { const application = require(`${dir}/.production/server`).default const projectName = application.project.name || 'The Nullstack application' - console.info('\x1b[36m%s\x1b[0m', `\n βœ…οΈ ${projectName} is ready for production\n`) + console.info('\x1b[36m%s\x1b[0m', `\n πŸš€ ${projectName} is ready for production\n`) if (cache) { console.info('Storing cache...') diff --git a/client/client.js b/client/client.js index 3e4b821d..18c993fa 100644 --- a/client/client.js +++ b/client/client.js @@ -26,7 +26,7 @@ client.nextHead = [] client.head = document.head client.body = document.body -client.update = async function update() { +client.update = function update() { if (client.initialized) { clearInterval(client.renderQueue) client.renderQueue = setTimeout(async () => { @@ -39,9 +39,13 @@ client.update = async function update() { client.nextVirtualDom = await generateTree(client.initializer(), scope) rerender() client.processLifecycleQueues() - } catch (e) { + } catch (error) { client.skipHotReplacement = true - console.error(e) + if (context.catch) { + context.catch(error) + } else { + throw error + } } }, 16) } @@ -97,4 +101,8 @@ client.processLifecycleQueues = async function processLifecycleQueues() { router._changed = false } +if (module.hot) { + client.klasses = {} +} + export default client diff --git a/client/index.js b/client/index.js index adcdf6a5..60660942 100644 --- a/client/index.js +++ b/client/index.js @@ -1,5 +1,4 @@ import element from '../shared/element' -import fragment from '../shared/fragment' import generateTree from '../shared/generateTree' import { loadPlugins, useClientPlugins } from '../shared/plugins' import client from './client' @@ -7,7 +6,6 @@ import context, { generateContext } from './context' import environment from './environment' import hydrate from './hydrate' import instanceProxyHandler, { instanceProxies } from './instanceProxyHandler' -import invoke from './invoke' import page from './page' import params, { updateParams } from './params' import project from './project' @@ -16,7 +14,6 @@ import rerender from './rerender' import router from './router' import settings from './settings' import state from './state' -import windowEvent from './windowEvent' import worker from './worker' context.page = page @@ -35,11 +32,12 @@ scope.context = context client.plugins = loadPlugins(scope) +if (environment.development) { + globalThis.$nullstack = context +} + export default class Nullstack { - static element = element - static invoke = invoke - static fragment = fragment static use = useClientPlugins static context = generateContext({}) @@ -104,48 +102,3 @@ export default class Nullstack { } } - -if (module.hot) { - Nullstack.serverHashes ??= {} - Nullstack.serverPings = 0 - Nullstack.clientPings = 0 - const socket = new WebSocket(`ws${router.base.slice(4)}/ws`) - socket.onmessage = async function (e) { - const data = JSON.parse(e.data) - if (data.type === 'NULLSTACK_SERVER_STARTED') { - Nullstack.serverPings++ - if (Nullstack.needsReload || !environment.hot) { - window.location.reload() - } - } - } - Nullstack.updateInstancesPrototypes = function updateInstancesPrototypes(klass, hash, serverHash) { - for (const key in context.instances) { - const instance = context.instances[key] - if (instance.constructor.hash === hash) { - Object.setPrototypeOf(instance, klass.prototype) - } - } - if (Nullstack.serverHashes[hash]) { - if (Nullstack.serverHashes[hash] !== serverHash) { - if (Nullstack.clientPings < Nullstack.serverPings) { - window.location.reload() - } else { - Nullstack.needsReload = true - } - } - Nullstack.clientPings++ - } - Nullstack.serverHashes[hash] = serverHash - client.update() - } - Nullstack.hotReload = function hotReload(klass) { - if (client.skipHotReplacement) { - window.location.reload() - } else { - Nullstack.start(klass) - windowEvent('environment') - } - } - module.hot.decline() -} diff --git a/client/invoke.js b/client/invoke.js index c5b78d4c..83b6df1a 100644 --- a/client/invoke.js +++ b/client/invoke.js @@ -2,6 +2,7 @@ import deserialize from '../shared/deserialize' import prefix from '../shared/prefix' import page from './page' import worker from './worker' +import client from './client' export default function invoke(name, hash) { return async function _invoke(params = {}) { @@ -12,8 +13,12 @@ export default function invoke(name, hash) { } else { worker.queues[name] = [...worker.queues[name], params] } - const finalHash = hash === this.hash ? hash : `${hash}-${this.hash}` + let finalHash = hash === this.hash ? hash : `${hash}-${this.hash}` let url = `${worker.api}/${prefix}/${finalHash}/${name}.json` + if (module.hot) { + const version = client.klasses[hash].__hashes[name] + url = `${worker.api}/${prefix}/${version}/${finalHash}/${name}.json` + } const body = JSON.stringify(params || {}) const options = { headers: worker.headers, diff --git a/client/lazy.js b/client/lazy.js new file mode 100644 index 00000000..46a0ed2e --- /dev/null +++ b/client/lazy.js @@ -0,0 +1,37 @@ +import client from './client' + +const queue = {} +let next = null + +async function preload() { + cancelIdleCallback(next) + let entry = Object.entries(queue)[0] + if (!entry) return + let loader = entry[1] + if (!loader) return + await loader.load() + next = requestIdleCallback(preload) +} + +window.addEventListener('blur', () => { + preload() +}) + +window.addEventListener('focus', () => { + cancelIdleCallback(next) +}) + +export default function lazy(hash, importer) { + const loader = { + load: async () => { + const mod = await importer() + loader.component = mod.default + delete queue[hash] + client.update() + }, + component: null, + __nullstack_lazy: true + } + queue[hash] = loader + return loader +} \ No newline at end of file diff --git a/client/logo.njs b/client/logo.njs new file mode 100644 index 00000000..d05912d4 --- /dev/null +++ b/client/logo.njs @@ -0,0 +1 @@ +export { default } from '../logo' \ No newline at end of file diff --git a/client/page.js b/client/page.js index c0ae045b..dbf9d95c 100644 --- a/client/page.js +++ b/client/page.js @@ -11,12 +11,22 @@ delete state.page const pageProxyHandler = { set(target, name, value, receiver) { - if (name === 'title') { - document.title = value - } const result = Reflect.set(target, name, value, receiver) if (name === 'title') { + document.title = value + document.querySelector('head > meta[property="og:title"]').setAttribute('content', value) windowEvent('page') + } else if (name === 'description') { + document.querySelector('head > meta[name="description"]').setAttribute('content', value) + document.querySelector('head > meta[property="og:description"]').setAttribute('content', value) + } else if (name === 'locale') { + document.querySelector('html').setAttribute('lang', value) + document.querySelector('head > meta[property="og:locale"]').setAttribute('content', value) + } else if (name === 'image') { + document.querySelector('head > meta[property="og:image"]').setAttribute('content', value) + } else if (name === 'canonical') { + canonical = (path.indexOf('//') === -1) ? router.base + value : value + document.querySelector('head > link[rel="canonical"]').setAttribute('href', canonical) } client.update() return result diff --git a/client/render.js b/client/render.js index a71e4eac..14673b10 100644 --- a/client/render.js +++ b/client/render.js @@ -1,3 +1,4 @@ +import { sanitizeInnerHtml } from '../shared/sanitizeString' import generateTruthyString from '../shared/generateTruthyString' import { isFalse, isText } from '../shared/nodes' import { anchorableElement } from './anchorableNode' @@ -28,7 +29,7 @@ export default function render(node, options) { for (const name in node.attributes) { if (name === 'debounce') continue if (name === 'html') { - node.element.innerHTML = node.attributes[name] + node.element.innerHTML = sanitizeInnerHtml(node.attributes[name]) node.head || anchorableElement(node.element) } else if (name.startsWith('on')) { if (node.attributes[name] !== undefined) { diff --git a/client/rerender.js b/client/rerender.js index 9117b739..34f93f4d 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -1,3 +1,4 @@ +import { sanitizeInnerHtml } from '../shared/sanitizeString' import generateTruthyString from '../shared/generateTruthyString' import { isFalse, isText, isUndefined } from '../shared/nodes' import { anchorableElement } from './anchorableNode' @@ -14,7 +15,7 @@ function updateAttributes(selector, currentAttributes, nextAttributes) { reref(nextAttributes, selector) } else if (name === 'html') { if (nextAttributes[name] !== currentAttributes[name]) { - selector.innerHTML = nextAttributes[name] + selector.innerHTML = sanitizeInnerHtml(nextAttributes[name]) anchorableElement(selector) } } else if (name === 'checked' || name === 'value') { diff --git a/client/router.js b/client/router.js index cbf1e536..ab0b9b12 100644 --- a/client/router.js +++ b/client/router.js @@ -6,6 +6,7 @@ import { updateParams } from './params' import segments from './segments' import windowEvent from './windowEvent' import worker from './worker' +import deserialize from '../shared/deserialize' let redirectTimer = null @@ -40,7 +41,8 @@ class Router { const endpoint = path === '/' ? api : path + api try { const response = await fetch(endpoint) - const payload = await response.json(url) + const meta = await response.text() + const payload = deserialize(meta) client.memory = payload.instances for (const key in payload.page) { page[key] = payload.page[key] diff --git a/client/runtime.js b/client/runtime.js new file mode 100644 index 00000000..8942468e --- /dev/null +++ b/client/runtime.js @@ -0,0 +1,72 @@ +import element from '../shared/element' +import fragment from '../shared/fragment' +import invoke from './invoke' +import context from './context' +import windowEvent from './windowEvent' +import client from './client' +import lazy from './lazy' + +const $runtime = { + element, + fragment, + invoke, + lazy +} + +if (module.hot) { + $runtime.dependencies = new Map() + + $runtime.accept = function accept(target, file, dependencies, declarations) { + target.hot.accept() + let initiateQueue = [] + const old = $runtime.dependencies.get(file) + if (old) { + if (old.length !== dependencies.length) { + return window.location.reload() + } + for (const index in old) { + if (old[index] !== dependencies[index]) { + return window.location.reload() + } + } + } + $runtime.dependencies.set(file, dependencies) + if (client.skipHotReplacement) { + return window.location.reload() + } + for (const declaration of declarations) { + let oldConstructor + for (const key in context.instances) { + const instance = context.instances[key] + if (instance.constructor.hash === declaration.klass.hash) { + oldConstructor = instance.constructor + Object.setPrototypeOf(instance, declaration.klass.prototype) + if (oldConstructor.__hashes !== undefined) { + for (const dep of declaration.initiate) { + if (oldConstructor.__hashes[dep] !== declaration.hashes[dep]) { + initiateQueue.push(instance) + break + } + } + } + } + } + client.klasses[declaration.klass.hash] = declaration.klass + declaration.klass.__hashes = declaration.hashes + } + windowEvent('environment') + for (const instance of initiateQueue) { + instance.initiate() + } + client.update() + } +} + +$runtime.restart = function restart(target, path) { + target.hot.accept() + target.hot.accept(path, () => { + window.location.reload() + }) +} + +export default $runtime \ No newline at end of file diff --git a/client/worker.js b/client/worker.js index 3a079dfd..c96008f6 100644 --- a/client/worker.js +++ b/client/worker.js @@ -38,6 +38,9 @@ async function register() { const request = `/service-worker.js` try { proxy.registration = await navigator.serviceWorker.register(request, { scope: '/' }) + if (environment.development) { + proxy.registration.unregister() + } } catch (error) { console.error(error) } diff --git a/loaders/add-source-to-node.js b/loaders/add-source-to-node.js deleted file mode 100644 index c92bd5a0..00000000 --- a/loaders/add-source-to-node.js +++ /dev/null @@ -1,43 +0,0 @@ -const parse = require('@babel/parser').parse -const traverse = require('@babel/traverse').default - -module.exports = function (source) { - this.cacheable && this.cacheable() - const uniquePositions = new Set() - const ast = parse(source, { - sourceType: 'module', - plugins: ['classProperties', 'jsx', 'typescript'], - }) - traverse(ast, { - JSXIdentifier(path) { - if (path.parent.type === 'JSXAttribute') { - if (path.node.name.startsWith('on')) { - const element = path.findParent((p) => p.type === 'JSXOpeningElement' && p.node.attributes) - const hasSource = element.node.attributes.find((a) => { - return a.type === 'JSXAttribute' && a.name.name === 'source' - }) - if (!hasSource) { - const start = element.node.attributes[0].start - uniquePositions.add(start) - } - } - } - }, - }) - if (uniquePositions.size === 0) return source - const positions = [...uniquePositions] - positions.reverse() - positions.push(0) - const outputs = [] - let last - for (const position of positions) { - const code = source.slice(position, last) - last = position - outputs.push(code) - if (position) { - outputs.push(`source={this} `) - } - } - - return outputs.reverse().join('') -} diff --git a/loaders/ignore-import.js b/loaders/ignore-import.js deleted file mode 100644 index a980b3a1..00000000 --- a/loaders/ignore-import.js +++ /dev/null @@ -1,10 +0,0 @@ -'use strict' -/* -Copyright (c) 2016 Cherry Ng -MIT Licensed -https://npmjs.com/package/ignore-loader -*/ -module.exports = function () { - this.cacheable && this.cacheable() - return '' -} diff --git a/loaders/inject-hmr.js b/loaders/inject-hmr.js deleted file mode 100644 index 24dd6ae2..00000000 --- a/loaders/inject-hmr.js +++ /dev/null @@ -1,39 +0,0 @@ -const parse = require('@babel/parser').parse -const traverse = require('@babel/traverse').default - -module.exports = function (source) { - const ast = parse(source, { - sourceType: 'module', - plugins: ['classProperties', 'jsx'], - }) - let klassName - let klassPath - traverse(ast, { - MemberExpression(path) { - if (path.node.property.name === 'start' && path.node.object && path.node.object.name === 'Nullstack') { - klassName = path.parent.arguments[0].name - } - }, - }) - if (!klassName) return source - traverse(ast, { - ImportDeclaration(path) { - if (path.node.specifiers[0].local.name === klassName) { - klassPath = path.node.source.extra.rawValue - } - }, - }) - - return `${source} - if (module.hot) { - if (Nullstack.needsClientReload) { - window.location.reload() - } - module.hot.accept() - Nullstack.needsClientReload = true - module.hot.accept('${klassPath}', () => { - Nullstack.hotReload(${klassName}) - }) - } - ` -} diff --git a/loaders/inject-nullstack.js b/loaders/inject-nullstack.js deleted file mode 100644 index e58d65c0..00000000 --- a/loaders/inject-nullstack.js +++ /dev/null @@ -1,21 +0,0 @@ -const parse = require('@babel/parser').parse -const traverse = require('@babel/traverse').default - -module.exports = function (source) { - const ast = parse(source, { - sourceType: 'module', - plugins: ['classProperties', 'jsx'], - }) - let shouldImport = true - traverse(ast, { - ImportDeclaration(path) { - if (path.node.source.value === 'nullstack') { - shouldImport = false - } - }, - }) - if (shouldImport) { - source = `import Nullstack from 'nullstack'\n${source}` - } - return source -} diff --git a/loaders/inject-runtime.js b/loaders/inject-runtime.js new file mode 100644 index 00000000..107b0a97 --- /dev/null +++ b/loaders/inject-runtime.js @@ -0,0 +1,4 @@ +module.exports = function (source, map) { + const injection = "import $runtime from 'nullstack/runtime';" + this.callback(null, injection + source, map) +} \ No newline at end of file diff --git a/loaders/register-inner-components.js b/loaders/register-inner-components.js deleted file mode 100644 index ab0db29e..00000000 --- a/loaders/register-inner-components.js +++ /dev/null @@ -1,59 +0,0 @@ -const parse = require('@babel/parser').parse -const traverse = require('@babel/traverse').default - -module.exports = function (source) { - const injections = {} - const positions = [] - const ast = parse(source, { - sourceType: 'module', - plugins: ['classProperties', 'jsx', 'typescript'], - }) - traverse(ast, { - ClassMethod(path) { - function identify(subpath) { - if (/^[A-Z]/.test(subpath.node.name)) { - if (!path.scope.hasBinding(subpath.node.name)) { - const start = path.node.body.body[0].start - if (!positions.includes(start)) { - positions.push(start) - } - if (!injections[start]) { - injections[start] = [] - } - if (!injections[start].includes(subpath.node.name)) { - injections[start].push(subpath.node.name) - } - } - } - } - if (path.node.key.name.startsWith('render')) { - traverse( - path.node, - { - JSXIdentifier: identify, - Identifier: identify, - }, - path.scope, - path, - ) - } - }, - }) - positions.reverse() - positions.push(0) - const outputs = [] - let last - for (const position of positions) { - const code = source.slice(position, last) - last = position - outputs.push(code) - if (position) { - for (const injection of injections[position]) { - if (injection) { - outputs.push(`const ${injection} = this.render${injection};\n `) - } - } - } - } - return outputs.reverse().join('') -} diff --git a/loaders/register-static-from-server.js b/loaders/register-static-from-server.js deleted file mode 100644 index 36b629e1..00000000 --- a/loaders/register-static-from-server.js +++ /dev/null @@ -1,41 +0,0 @@ -const parse = require('@babel/parser').parse -const traverse = require('@babel/traverse').default -const crypto = require('crypto') - -module.exports = function (source) { - let hasClass = false - const legacyHash = crypto.createHash('md5').update(source).digest('hex') - const id = this.resourcePath.replace(this.rootContext, '') - const hash = crypto.createHash('md5').update(id).digest('hex') - let klassName - let klassEnd - const methodNames = [] - const ast = parse(source, { - sourceType: 'module', - plugins: ['classProperties', 'jsx'], - }) - traverse(ast, { - ClassDeclaration(path) { - hasClass = true - klassEnd = path.node.end - 1 - klassName = path.node.id.name - }, - ClassMethod(path) { - if (path.node.static && path.node.async && !path.node.key.name.startsWith('_')) { - methodNames.push(path.node.key.name) - } - }, - }) - if (!hasClass) return source - let output = source.substring(0, klassEnd) - output += source.substring(klassEnd) - for (const methodName of methodNames) { - output += `\nNullstack.registry["${hash}.${methodName}"] = ${klassName}.${methodName};` - output += `\nNullstack.registry["${legacyHash}.${methodName}"] = ${klassName}.${methodName};` - } - output += `\nNullstack.registry["${hash}"] = ${klassName};` - output += `\nNullstack.registry["${legacyHash}"] = ${klassName};` - output += `\n${klassName}.hash = "${hash}";` - output += `\n${klassName}.bindStaticFunctions(${klassName});` - return output -} diff --git a/loaders/remove-import-from-client.js b/loaders/remove-import-from-client.js deleted file mode 100644 index 80d2d211..00000000 --- a/loaders/remove-import-from-client.js +++ /dev/null @@ -1,52 +0,0 @@ -const parse = require('@babel/parser').parse -const traverse = require('@babel/traverse').default - -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') { - const parent = path.findParent((p) => p.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 } - } - } - function findIdentifiers(path) { - 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) { - if (path.parent.type !== 'MemberExpression' || path.parent.object?.type !== 'ThisExpression') { - delete imports[name] - } - } - } - } - } - } - traverse(ast, { - ImportSpecifier: findImports, - ImportDefaultSpecifier: findImports, - ImportNamespaceSpecifier: findImports, - }) - traverse(ast, { - Identifier: findIdentifiers, - JSXIdentifier: findIdentifiers, - }) - const lines = Object.keys(imports) - .map((name) => imports[name].lines) - .flat() - return source - .split(`\n`) - .filter((line, index) => !lines.includes(index + 1)) - .join(`\n`) -} diff --git a/loaders/remove-static-from-client.js b/loaders/remove-static-from-client.js deleted file mode 100644 index 3a999d4f..00000000 --- a/loaders/remove-static-from-client.js +++ /dev/null @@ -1,65 +0,0 @@ -const parse = require('@babel/parser').parse -const traverse = require('@babel/traverse').default -const crypto = require('crypto') - -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 = {} - const positions = [] - const ast = parse(source, { - sourceType: 'module', - plugins: ['classProperties', 'jsx'], - }) - traverse(ast, { - ClassDeclaration(path) { - klassName = path.node.id.name - }, - ClassBody(path) { - const start = path.node.body[0].start - hashPosition = start - positions.push(start) - }, - 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) - } - } - }, - }) - positions.reverse() - positions.push(0) - const outputs = [] - let last - for (const position of positions) { - let code = source.slice(position, last) - last = position - const injection = injections[position] - if (position && injection) { - const location = injection.end - position - if (injection.name.startsWith('_')) { - code = code.substring(location).trimStart() - } else { - code = `static ${injection.name} = Nullstack.invoke('${injection.name}', '${hash}');${code.substring(location)}` - } - outputs.push(code) - } else { - outputs.push(code) - } - if (position === hashPosition) { - outputs.push(`static hash = '${hash}';\n\n `) - } - } - let newSource = outputs.reverse().join('') - if (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 -} diff --git a/loaders/shut-up-loader.js b/loaders/shut-up-loader.js new file mode 100644 index 00000000..355888b1 --- /dev/null +++ b/loaders/shut-up-loader.js @@ -0,0 +1,12 @@ +module.exports = function removeStaticFromClient(source) { + const injection = ` + const console = { + log: () => {}, + error: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {} + }; + ` + return injection + source +} diff --git a/loaders/string-replace.js b/loaders/string-replace.js deleted file mode 100644 index 3ac18f6a..00000000 --- a/loaders/string-replace.js +++ /dev/null @@ -1,20 +0,0 @@ -/* -Copyright (c) 2015 Valentyn Barmashyn -MIT Licensed -Original: https://npmjs.com/package/string-replace-loader -*/ -module.exports = function (source, map) { - this.cacheable() - - const optionsArray = this.getOptions().multiple - let newSource = source - - for (const options of optionsArray) { - newSource = newSource.replace( - new RegExp(options.search, options.search.flags || options.flags || ''), - options.replace, - ) - } - - this.callback(null, newSource, map) -} diff --git a/loaders/trace.js b/loaders/trace.js new file mode 100644 index 00000000..42d5c4a0 --- /dev/null +++ b/loaders/trace.js @@ -0,0 +1,4 @@ +module.exports = function (source, map) { + console.info(` [${process.env.__NULLSTACK_TARGET}] ${this.resourcePath}`) + this.callback(null, source, map) +} \ No newline at end of file diff --git a/loaders/transform-node-ref.js b/loaders/transform-node-ref.js deleted file mode 100644 index 6b3a813a..00000000 --- a/loaders/transform-node-ref.js +++ /dev/null @@ -1,49 +0,0 @@ -const parse = require('@babel/parser').parse -const traverse = require('@babel/traverse').default - -const attributes = ['ref', 'bind'] - -module.exports = function removeStaticFromClient(source) { - const ast = parse(source, { - sourceType: 'module', - plugins: ['classProperties', 'jsx', 'typescript'], - }) - const refs = [] - traverse(ast, { - JSXAttribute(path) { - const attribute = path.node.name.name - if (attributes.includes(attribute)) { - const expression = path.node.value.expression - if (expression.type !== 'Identifier') { - const object = expression.object - const property = expression.property - const refObject = source.slice(object.start, object.end) - let refProperty = source.slice(property.start, property.end) - if (property.type === 'Identifier' && !expression.computed) { - refProperty = `'${refProperty}'` - } - const replacement = `${attribute}={{object: ${refObject}, property: ${refProperty}}}` - refs.push({ - start: path.node.start, - end: path.node.end, - replacement, - }) - } - } - }, - }) - if (refs.length === 0) return source - const sources = [] - for (let i = 0; i <= refs.length; i++) { - const prev = refs[i - 1] - const current = refs[i] - const start = prev ? prev.end : 0 - const end = current ? current.start : undefined - const before = source.slice(start, end) - sources.push(before) - if (current) { - sources.push(current.replacement) - } - } - return sources.join('') -} diff --git a/nullstack.js b/nullstack.js deleted file mode 100644 index 53d12948..00000000 --- a/nullstack.js +++ /dev/null @@ -1,4 +0,0 @@ -// eslint-disable-next-line import/no-unresolved -import Nullstack from './{{NULLSTACK_ENVIRONMENT_NAME}}' - -export default Nullstack diff --git a/package.json b/package.json index bf2b945e..9e7e4ba1 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "nullstack", - "version": "0.17.5", + "version": "0.18.0", "description": "Full Stack Javascript Components for one-dev armies", - "main": "nullstack.js", + "main": "./types/index.d.ts", "author": "Mortaro", "repository": "github:nullstack/nullstack", "homepage": "https://nullstack.app", @@ -16,34 +16,42 @@ }, "dependencies": { "@babel/core": "^7.18.13", - "@babel/parser": "7.17.12", + "@babel/parser": "7.20.15", "@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-env": "^7.18.10", "@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", + "@babel/traverse": "7.20.13", + "@swc/core": "1.3.35", + "babel-loader": "9.1.2", + "body-parser": "1.20.1", + "commander": "10.0.0", "copy-webpack-plugin": "^11.0.0", - "css-loader": "6.7.1", - "dotenv": "8.6.0", - "eslint-plugin-jest": "^27.1.6", - "eslint-plugin-nullstack": "^0.0.11", - "express": "4.18.1", - "fs-extra": "10.1.0", - "mini-css-extract-plugin": "2.6.0", + "css-loader": "6.7.3", + "css-minimizer-webpack-plugin": "^4.2.2", + "dotenv": "16.0.3", + "eslint-plugin-nullstack": "0.0.12", + "express": "4.18.2", + "fs-extra": "11.1.0", + "lightningcss": "^1.19.0", + "mini-css-extract-plugin": "2.7.2", "node-fetch": "2.6.7", "nodemon-webpack-plugin": "^4.8.1", - "sass": "1.51.0", - "sass-loader": "8.0.2", - "swc-loader": "0.2.1", - "terser-webpack-plugin": "5.3.1", - "webpack": "5.72.1", - "webpack-dev-server": "4.9.0", - "ws": "7.5.7" + "sass": "1.58.0", + "sass-loader": "13.2.0", + "style-loader": "^3.3.1", + "swc-loader": "0.2.3", + "swc-plugin-nullstack": "0.1.2", + "terser-webpack-plugin": "5.3.6", + "time-analytics-webpack-plugin": "^0.1.20", + "webpack": "^5.0.0", + "webpack-dev-server": "4.11.1", + "webpack-hot-middleware": "^2.25.3", + "ws": "8.12.0" + }, + "devDependencies": { + "webpack-dev-middleware": "github:Mortaro/webpack-dev-middleware#fix-write-to-disk-cleanup" } } \ No newline at end of file diff --git a/scripts/index.js b/scripts/index.js index 23fae58b..386ee75e 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -1,14 +1,14 @@ #! /usr/bin/env node const { program } = require('commander') const dotenv = require('dotenv') -const { existsSync, readdir, unlink } = require('fs') -const fetch = require('node-fetch') +const { existsSync, readdirSync, unlinkSync } = require('fs') const path = require('path') -const webpack = require('webpack') +const webpack = require(`webpack`) const { version } = require('../package.json') -const customConfig = path.resolve(process.cwd(), './webpack.config.js') -const config = existsSync(customConfig) ? require(customConfig) : require('../webpack.config') +const customConfig = path.resolve(process.cwd(), 'webpack.config.js') +const nullstackConfig = path.resolve(process.cwd(), 'node_modules', 'nullstack', 'webpack.config.js') +const config = existsSync(customConfig) ? require(customConfig) : require(nullstackConfig) function getConfig(options) { return config.map((env) => env(null, options)) @@ -18,150 +18,77 @@ function getCompiler(options) { return webpack(getConfig(options)) } -function loadEnv(env) { +function loadEnv(name) { let envPath = '.env' - if (env) { - envPath += `.${process.env.NULLSTACK_ENVIRONMENT_NAME}` + if (name) { + envPath += `.${name}` } dotenv.config({ path: envPath }) } -function getFreePorts() { - return new Promise((resolve) => { - const app1 = require('express')() - const app2 = require('express')() - const server1 = app1.listen(0, () => { - const server2 = app2.listen(0, () => { - const ports = [server1.address().port, server2.address().port] - server1.close() - server2.close() - resolve(ports) - }) - }) - }) -} - -function getPort(port) { - return port || process.env.NULLSTACK_SERVER_PORT || process.env.PORT || 3000 -} - -function clearOutput(outputPath) { - if (!existsSync(outputPath)) return - readdir(outputPath, (err, files) => { - if (err) throw err - for (const file of files) { - if (file === '.cache') continue - unlink(path.join(outputPath, file), (errr) => { - if (errr) throw errr - }) +function clearDir() { + if (existsSync(path.join(process.cwd(), '.development'))) { + const tempFiles = readdirSync(path.join(process.cwd(), '.development')) + for (const file of tempFiles) { + if (file !== '.cache') { + unlinkSync(path.join(process.cwd(), '.development', file)) + } } - }) + } } -async function start({ input, port, env, mode = 'spa', cold, disk, loader = 'swc' }) { +async function start({ port, name, disk, skipCache, trace }) { + process.env.__NULLSTACK_TRACE = (!!trace).toString() + const progress = require('../builders/logger')('server', 'development') + loadEnv(name) const environment = 'development' - console.info(` πŸš€οΈ Starting your application in ${environment} mode...`) - loadEnv(env) - const WebpackDevServer = require('webpack-dev-server') - const { setLogLevel } = require('webpack/hot/log') - setLogLevel('none') - process.env.NULLSTACK_ENVIRONMENT_MODE = mode - process.env.NULLSTACK_SERVER_PORT = getPort(port) - const ports = await getFreePorts() - process.env.NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT = ports[0] - process.env.NULSTACK_SERVER_SOCKET_PORT_YOU_SHOULD_NOT_CARE_ABOUT = ports[1] - process.env.NULLSTACK_ENVIRONMENT_HOT = (!cold).toString() + process.env.NULLSTACK_ENVIRONMENT_MODE = 'spa' process.env.NULLSTACK_ENVIRONMENT_DISK = (!!disk).toString() + process.env.__NULLSTACK_CLI_ENVIRONMENT = environment + if (name) { + process.env.NULLSTACK_ENVIRONMENT_NAME = name + } + if (port) { + process.env.NULLSTACK_SERVER_PORT = port + } if (!process.env.NULLSTACK_PROJECT_DOMAIN) process.env.NULLSTACK_PROJECT_DOMAIN = 'localhost' if (!process.env.NULLSTACK_WORKER_PROTOCOL) process.env.NULLSTACK_WORKER_PROTOCOL = 'http' - const target = `${process.env.NULLSTACK_WORKER_PROTOCOL}://${process.env.NULLSTACK_PROJECT_DOMAIN}:${process.env.NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT}` - const writeToDisk = disk ? true : (p) => p.includes('server') - const devServerOptions = { - hot: 'only', - open: false, - host: process.env.NULLSTACK_PROJECT_DOMAIN, - devMiddleware: { - index: false, - stats: 'errors-only', - writeToDisk, - }, - client: { - overlay: { errors: true, warnings: false }, - logging: 'none', - progress: false, - reconnect: true, - webSocketURL: `${process.env.NULLSTACK_WORKER_PROTOCOL.replace('http', 'ws')}://${ - process.env.NULLSTACK_PROJECT_DOMAIN - }:${process.env.NULLSTACK_SERVER_PORT}/ws`, - }, - proxy: { - context: () => true, - target, - proxyTimeout: 10 * 60 * 1000, - timeout: 10 * 60 * 1000, - }, - setupMiddlewares: (middlewares, devServer) => { - if (!devServer) { - throw new Error('webpack-dev-server is not defined') + const settings = config[0](null, { environment, disk, skipCache, name, trace }) + const compiler = webpack(settings) + clearDir() + compiler.watch({ aggregateTimeout: 200, hot: true, ignored: /node_modules/ }, (error, stats) => { + progress.stop() + if (error) { + console.error(error.stack || error) + if (error.details) { + console.error(error.details) } - middlewares.unshift(async (req, res, next) => { - if (req.originalUrl.indexOf('.hot-update.') === -1) { - if (req.originalUrl.startsWith('/nullstack/')) { - console.info(` βš™οΈ [${req.method}] ${req.originalUrl}`) - } else { - console.info(` πŸ•ΈοΈ [${req.method}] ${req.originalUrl}`) - } - } - async function waitForServer() { - if (req.originalUrl.includes('.')) { - return next() - } - try { - await fetch(`${target}${req.originalUrl}`) - next() - } catch (error) { - if (error.message.includes('ECONNREFUSED')) { - setTimeout(waitForServer, 100) - } else { - throw error - } - } - } - waitForServer() - }) - return middlewares - }, - webSocketServer: require.resolve('./socket'), - port: process.env.NULLSTACK_SERVER_PORT, - } - 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, () => { - portChecker.close() - server.startCallback(() => { - console.info( - '\x1b[36m%s\x1b[0m', - ` βœ…οΈ Your application is ready at http://${process.env.NULLSTACK_PROJECT_DOMAIN}:${process.env.NULLSTACK_SERVER_PORT}\n`, - ) - }) + } else if (stats.hasErrors()) { + console.info(stats.toString({ colors: true, warnings: false, logging: false, assets: false, modules: false })) + } }) } -function build({ input, output, cache, env, mode = 'ssr' }) { +function build({ mode = 'ssr', output, name, skipCache }) { const environment = 'production' - const compiler = getCompiler({ environment, input, cache }) - if (env) { - process.env.NULLSTACK_ENVIRONMENT_NAME = env + const progress = require('../builders/logger')('application', environment) + const compiler = getCompiler({ environment, skipCache, name }) + if (name) { + process.env.NULLSTACK_ENVIRONMENT_NAME = name } - console.info(` πŸš€οΈ Building your application in ${mode} mode...`) compiler.run((error, stats) => { - if (stats.hasErrors()) { + if (error) { + console.error(error.stack || error) + if (error.details) { + console.error(error.details) + } + } else if (stats.hasErrors()) { console.info(stats.toString({ colors: true })) process.exit(1) } if (stats.hasErrors()) process.exit(1) - require(`../builders/${mode}`)({ output, cache, environment }) + progress.stop() + require(`../builders/${mode}`)({ output, environment }) }) } @@ -169,13 +96,11 @@ program .command('start') .alias('s') .description('Start application in development environment') - .addOption(new program.Option('-m, --mode ', 'Build production bundles').choices(['ssr', 'spa'])) .option('-p, --port ', 'Port number to run the server') - .option('-i, --input ', 'Path to project that will be started') - .option('-e, --env ', 'Name of the environment file that should be loaded') + .option('-n, --name ', 'Name of the environment. Affects which .env file that will 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'])) + .option('-sc, --skip-cache', 'Skip loding and building cache in .development folder') + .option('-t, --trace', 'Trace file compilation') .helpOption('-h, --help', 'Learn more about this command') .action(start) @@ -184,10 +109,9 @@ program .alias('b') .description('Build application for production environment') .addOption(new program.Option('-m, --mode ', 'Build production bundles').choices(['ssr', 'spa', 'ssg'])) - .option('-i, --input ', 'Path to project that will be built') .option('-o, --output ', 'Path to build output folder') - .option('-c, --cache', 'Cache build results in .production folder') - .option('-e, --env ', 'Name of the environment file that should be loaded') + .option('-n, --name ', 'Name of the environment. Affects which .env file that will be loaded') + .option('-sc, --skip-cache', 'Skip loding and building cache in .production folder') .helpOption('-h, --help', 'Learn more about this command') .action(build) diff --git a/scripts/socket.js b/scripts/socket.js deleted file mode 100644 index f07cd572..00000000 --- a/scripts/socket.js +++ /dev/null @@ -1,74 +0,0 @@ -'use strict' - -const BaseServer = require('webpack-dev-server/lib/servers/BaseServer') -const WebSocket = require('ws') - -class WebsocketServer extends BaseServer { - - constructor(server) { - super(server) - - const options = { - ...this.server.options.webSocketServer.options, - clientTracking: false, - port: process.env.NULSTACK_SERVER_SOCKET_PORT_YOU_SHOULD_NOT_CARE_ABOUT, - } - - this.implementation = new WebSocket.Server(options) - - this.server.server.on('upgrade', (req, sock, head) => { - if (!this.implementation.shouldHandle(req)) { - return - } - this.implementation.handleUpgrade(req, sock, head, (connection) => { - this.implementation.emit('connection', connection, req) - }) - }) - - this.implementation.on('error', (err) => { - this.server.logger.error(err.message) - }) - - const interval = setInterval(() => { - this.clients.forEach((client) => { - if (client.isAlive === false) { - client.terminate() - - return - } - - client.isAlive = false - client.ping(() => {}) - }) - }, 1000) - - this.implementation.on('connection', (client) => { - this.clients.push(client) - - client.isAlive = true - - client.on('message', (data) => { - if (data === '{"type":"NULLSTACK_SERVER_STARTED"}') { - this.clients.forEach((c) => { - c.send('{"type":"NULLSTACK_SERVER_STARTED"}') - }) - } - }) - - client.on('pong', () => { - client.isAlive = true - }) - - client.on('close', () => { - this.clients.splice(this.clients.indexOf(client), 1) - }) - }) - - this.implementation.on('close', () => { - clearInterval(interval) - }) - } - -} - -module.exports = WebsocketServer diff --git a/server/dotenv.js b/server/dotenv.js index 6de25639..06107406 100644 --- a/server/dotenv.js +++ b/server/dotenv.js @@ -6,4 +6,8 @@ if (process.env.NULLSTACK_ENVIRONMENT_NAME) { path += `.${process.env.NULLSTACK_ENVIRONMENT_NAME}` } -dotenv.config({ path }) +if (module.hot) { + dotenv.config({ path, override: true }) +} else { + dotenv.config({ path }) +} diff --git a/server/emulatePrerender.js b/server/emulatePrerender.js new file mode 100644 index 00000000..8c2fc03d --- /dev/null +++ b/server/emulatePrerender.js @@ -0,0 +1,77 @@ +function createRequest(url) { + return { + method: 'GET', + host: '', + cookies: {}, + query: {}, + url, + headers: {}, + } +} + +function createResponse(callback) { + const res = { + _removedHeader: {}, + _statusCode: 200, + statusMessage: 'OK', + get statusCode() { + return this._statusCode + }, + set statusCode(status) { + this._statusCode = status + this.status(status) + }, + } + const headers = {} + let code = 200 + res.set = res.header = (x, y) => { + if (arguments.length === 2) { + res.setHeader(x, y) + } else { + for (const key in x) { + res.setHeader(key, x[key]) + } + } + return res + } + res.setHeader = (x, y) => { + headers[x] = y + headers[x.toLowerCase()] = y + return res + } + res.getHeader = (x) => headers[x] + res.redirect = function (_code, url) { + if (typeof _code !== 'number') { + code = 301 + url = _code + } else { + code = _code + } + res.setHeader('Location', url) + res.end() + } + res.status = res.sendStatus = function (number) { + code = number + return res + } + res.end = + res.send = + res.write = + function (data = '') { + if (callback) callback(code, data, headers) + } + return res +} + +export default function emulatePrerender(server) { + server.prerender = async function prerender(originalUrl) { + server.start() + return new Promise((resolve) => { + server._router.handle( + createRequest(originalUrl), + createResponse((code, data) => resolve(data)), + () => { }, + ) + }) + } +} \ No newline at end of file diff --git a/server/environment.js b/server/environment.js index 345eaeba..95a03d1b 100644 --- a/server/environment.js +++ b/server/environment.js @@ -1,3 +1,5 @@ +import { KEY } from 'nullstack/environment' + const environment = { client: false, server: true } environment.development = __dirname.indexOf('.development') > -1 @@ -5,14 +7,10 @@ environment.production = !environment.development environment.mode = process.env.NULLSTACK_ENVIRONMENT_MODE || 'ssr' -environment.key = '{{NULLSTACK_ENVIRONMENT_KEY}}' +environment.key = KEY environment.name = process.env.NULLSTACK_ENVIRONMENT_NAME || '' -if (environment.development) { - environment.hot = process.env.NULLSTACK_ENVIRONMENT_HOT === 'true' -} - Object.freeze(environment) export default environment diff --git a/server/exposeServerFunctions.js b/server/exposeServerFunctions.js new file mode 100644 index 00000000..45e55af0 --- /dev/null +++ b/server/exposeServerFunctions.js @@ -0,0 +1,57 @@ +import extractParamValue from '../shared/extractParamValue' +import fs from 'fs' +import bodyParser from 'body-parser' +import path from 'path' +import deserialize from '../shared/deserialize' +import { generateContext } from './context' +import printError from './printError' +import registry from './registry' +import reqres from './reqres' + +export default function exposeServerFunctions(server) { + for (const method of ['get', 'post', 'put', 'patch', 'delete', 'all']) { + const original = server[method].bind(server) + server[method] = function (...args) { + if (typeof args[1] === 'function' && args[1].name === '_invoke') { + return original(args[0], bodyParser.text({ limit: server.maximumPayloadSize }), async (request, response) => { + reqres.set(request, response) + const params = {} + for (const key of Object.keys(request.params)) { + params[key] = extractParamValue(request.params[key]) + } + for (const key of Object.keys(request.query)) { + params[key] = extractParamValue(request.query[key]) + } + if (request.method !== 'GET') { + const payload = typeof request.body === 'object' ? JSON.stringify(request.body) : request.body + Object.assign(params, deserialize(payload)) + } + try { + const subcontext = generateContext({ request, response, ...params }) + const exposedFunction = module.hot ? registry[args[1].hash] : args[1] + const result = await exposedFunction(subcontext) + reqres.clear() + response.json(result) + } catch (error) { + printError(error) + reqres.clear() + response.status(500).json({}) + } + }) + } + if (module.hot) { + server._router.stack.forEach((r) => { + if (r?.route?.path === args[0]) { + const exists = r.route.stack.find((l) => l.method === method) + if (!!exists && !!process.env.__NULLSTACK_FIRST_LOAD_COMPLETE) { + const filename = path.join(process.cwd(), 'server.js') + const time = new Date() + fs.utimesSync(filename, time, time) + } + } + }) + } + return original(...args) + } + } +} \ No newline at end of file diff --git a/server/hmr.js b/server/hmr.js new file mode 100644 index 00000000..7cdf6213 --- /dev/null +++ b/server/hmr.js @@ -0,0 +1,71 @@ +/* eslint-disable nullstack/no-undef */ +import { existsSync, open } from 'fs' +import path from 'path' +import logger from '../builders/logger' + +function waitCompiler(next) { + open(path.join(__dirname, '.compiling'), (error, exists) => { + if (!exists) { + next() + } else { + setTimeout(() => { + waitCompiler(next) + }, 100) + } + }) +} + +export default function hmr(server) { + const progress = logger('client', 'development') + + if (module.hot) { + const customConfig = path.resolve(process.cwd(), 'webpack.config.js') + const webpackConfigs = existsSync(customConfig) + ? __non_webpack_require__(customConfig) + : __non_webpack_require__(path.join(__dirname, '..', 'node_modules', 'nullstack', 'webpack.config.js')) + + function resolve(pkg) { + if (process.cwd().endsWith('/nullstack/tests') || process.cwd().endsWith('\\nullstack\\tests')) { + return path.join(__dirname, '..', '..', 'node_modules', pkg) + } + return pkg + } + + const webpack = __non_webpack_require__(resolve(`webpack`)) + const webpackDevMiddleware = __non_webpack_require__(resolve(`webpack-dev-middleware`)) + const disk = process.env.NULLSTACK_ENVIRONMENT_DISK === 'true' + const trace = process.env.__NULLSTACK_TRACE === 'true' + const webpackConfig = webpackConfigs[1](null, { + environment: process.env.__NULLSTACK_CLI_ENVIRONMENT, + input: process.env.__NULLSTACK_CLI_INPUT, + disk, + trace, + }) + const compiler = webpack(webpackConfig) + + const webpackDevMiddlewareOptions = { + publicPath: '/', + writeToDisk: disk, + } + + server.use(async (_request, _response, next) => { + waitCompiler(next) + }) + + const instance = webpackDevMiddleware(compiler, webpackDevMiddlewareOptions) + + instance.waitUntilValid(() => { + progress.stop() + console.info( + '\x1b[36m%s\x1b[0m', + `\n πŸš€ Your application is ready at http://${process.env.NULLSTACK_PROJECT_DOMAIN}:${process.env.NULLSTACK_SERVER_PORT || process.env.PORT || 3000}\n`, + ) + }) + + server.use(instance) + + const webpackHotMiddleware = __non_webpack_require__(resolve(`webpack-hot-middleware`)) + const webpackHotMiddlewareOptions = { log: false, path: '/nullstack/hmr', noinfo: true, quiet: true } + server.use(webpackHotMiddleware(compiler, webpackHotMiddlewareOptions)) + } +} diff --git a/server/index.js b/server/index.js index 3506606b..551173b1 100644 --- a/server/index.js +++ b/server/index.js @@ -2,23 +2,31 @@ import './dotenv' import { normalize } from 'path' import element from '../shared/element' -import fragment from '../shared/fragment' import getProxyableMethods from '../shared/getProxyableMethods' import { useServerPlugins } from '../shared/plugins' -import context, { generateContext } from './context' +import context from './context' import environment from './environment' import generator from './generator' import instanceProxyHandler from './instanceProxyHandler' import project from './project' -import registry from './registry' -import reqres from './reqres' import secrets from './secrets' import server from './server' import settings from './settings' import worker from './worker' +import fetch from 'node-fetch' globalThis.window = {} +if (!global.fetch) { + global.fetch = fetch +} + +if (!global.location) { + global.location = { + href: '/', + } +} + context.server = server context.project = project context.environment = environment @@ -28,46 +36,16 @@ context.worker = worker server.less = normalize(__filename) !== normalize(process.argv[1]) +if (environment.development) { + globalThis.$nullstack = context +} + class Nullstack { - static registry = registry - static element = element - static fragment = fragment static use = useServerPlugins - static bindStaticFunctions(klass) { - let parent = klass - while (parent.name !== 'Nullstack') { - const props = Object.getOwnPropertyNames(parent) - for (const prop of props) { - const underscored = prop.startsWith('_') - if (typeof klass[prop] === 'function') { - if (!underscored && !registry[`${parent.hash}.${prop}`]) { - return - } - const propName = `__NULLSTACK_${prop}` - if (!klass[propName]) { - klass[propName] = klass[prop] - } - function _invoke(...args) { - if (underscored) { - return klass[propName].call(klass, ...args) - } - const params = args[0] || {} - const { request, response } = reqres - const subcontext = generateContext({ request, response, ...params }) - return klass[propName].call(klass, subcontext) - } - klass[prop] = _invoke - klass.prototype[prop] = _invoke - } - } - parent = Object.getPrototypeOf(parent) - } - } - static start(Starter) { - if (this.name.indexOf('Nullstack') > -1) { + if (this === Nullstack) { generator.starter = () => element(Starter) setTimeout(server.start, 0) return context @@ -111,4 +89,4 @@ class Nullstack { } -export default Nullstack +export default Nullstack \ No newline at end of file diff --git a/server/lazy.js b/server/lazy.js new file mode 100644 index 00000000..f18f260f --- /dev/null +++ b/server/lazy.js @@ -0,0 +1,13 @@ +const queue = {} + +export default function lazy(hash, importer) { + queue[hash] = importer +} + +export async function load(hash) { + const fileHash = module.hot ? hash.split('___')[0] : hash.slice(0, 8) + if (!queue[fileHash]) return + const importer = queue[fileHash] + await importer() + delete queue[fileHash] +} \ No newline at end of file diff --git a/server/logo.njs b/server/logo.njs new file mode 100644 index 00000000..d05912d4 --- /dev/null +++ b/server/logo.njs @@ -0,0 +1 @@ +export { default } from '../logo' \ No newline at end of file diff --git a/server/manifest.js b/server/manifest.js index b7703061..0e46f818 100644 --- a/server/manifest.js +++ b/server/manifest.js @@ -8,7 +8,7 @@ import project from './project' export default function generateManifest(server) { if (files['manifest.webmanifest']) return files['manifest.webmanifest'] - const file = path.join(__dirname, '../', 'public', 'manifest.webmanifest') + const file = path.join(process.cwd(), 'public', 'manifest.webmanifest') if (existsSync(file)) { return readFileSync(file, 'utf-8') } diff --git a/server/project.js b/server/project.js index b5787720..b3c19e75 100644 --- a/server/project.js +++ b/server/project.js @@ -1,3 +1,4 @@ +import { ICONS } from 'nullstack/project' import environment from './environment' import reqres from './reqres' import worker from './worker' @@ -17,7 +18,7 @@ project.root = '/' project.sitemap = environment.mode === 'ssg' project.favicon = '/favicon-96x96.png' project.disallow = [] -project.icons = JSON.parse(`{{NULLSTACK_PROJECT_ICONS}}`) +project.icons = ICONS function getHost() { if (reqres.request?.headers?.host) { diff --git a/server/registry.js b/server/registry.js index 5305b2bd..f4c0c354 100644 --- a/server/registry.js +++ b/server/registry.js @@ -1,2 +1,51 @@ const registry = {} export default registry +import reqres from "./reqres" +import { generateContext } from "./context" +import Nullstack from '.' +import { load } from "./lazy" + +export function register(klass, functionName) { + if (functionName) { + registry[`${klass.hash}.${functionName}`] = klass[functionName] + } else { + registry[klass.hash] = klass + bindStaticProps(klass) + } +} + +function bindStaticProps(klass) { + let parent = klass + while (parent !== Nullstack) { + const props = Object.getOwnPropertyNames(parent) + for (const prop of props) { + if (prop === 'caller' || prop === 'callee' || prop === 'arguments') continue + const underscored = prop.startsWith('_') + if (typeof klass[prop] === 'function') { + if (!underscored && !registry[`${parent.hash}.${prop}`]) { + return + } + const propName = `__NULLSTACK_${prop}` + if (!klass[propName]) { + klass[propName] = klass[prop] + } + async function _invoke(...args) { + if (underscored) { + return klass[propName].call(klass, ...args) + } + const params = args[0] || {} + const { request, response } = reqres + const subcontext = generateContext({ request, response, ...params }) + await load(klass.hash) + return klass[propName].call(klass, subcontext) + } + if (module.hot) { + _invoke.hash = klass[prop].hash + } + klass[prop] = _invoke + klass.prototype[prop] = _invoke + } + } + parent = Object.getPrototypeOf(parent) + } +} \ No newline at end of file diff --git a/server/runtime.js b/server/runtime.js new file mode 100644 index 00000000..b0004da8 --- /dev/null +++ b/server/runtime.js @@ -0,0 +1,30 @@ +import element from '../shared/element' +import fragment from '../shared/fragment' +import { register } from './registry' +import lazy from './lazy' +import Nullstack from './index' + +const $runtime = { + element, + fragment, + register, + lazy +} + +if (module.hot) { + $runtime.accept = function accept(target, _file, _dependencies, declarations) { + target.hot.accept(); + for (const declaration of declarations) { + declaration.klass.__hashes = declaration.hashes + } + } + + $runtime.restart = function restart(target, path, klass) { + target.hot.accept() + target.hot.accept(path, () => { + Nullstack.start(klass) + }) + } +} + +export default $runtime \ No newline at end of file diff --git a/server/server.js b/server/server.js index 3f612891..3203483c 100644 --- a/server/server.js +++ b/server/server.js @@ -1,16 +1,14 @@ import bodyParser from 'body-parser' import express from 'express' -import { writeFileSync } from 'fs' -import fetch from 'node-fetch' import path from 'path' -import WebSocket from 'ws' - import deserialize from '../shared/deserialize' -import extractParamValue from '../shared/extractParamValue' import prefix from '../shared/prefix' import context, { generateContext } from './context' +import emulatePrerender from './emulatePrerender' import environment from './environment' +import exposeServerFunctions from './exposeServerFunctions' import { generateFile } from './files' +import hmr from './hmr' import generateManifest from './manifest' import { prerender } from './prerender' import printError from './printError' @@ -19,55 +17,15 @@ import reqres from './reqres' import generateRobots from './robots' import template from './template' import { generateServiceWorker } from './worker' - -if (!global.fetch) { - global.fetch = fetch -} +import { load } from './lazy' const server = express() -server.port = - process.env.NULSTACK_SERVER_PORT_YOU_SHOULD_NOT_CARE_ABOUT || - process.env.NULLSTACK_SERVER_PORT || - process.env.PORT || - 3000 +server.port = process.env.NULLSTACK_SERVER_PORT || process.env.PORT || 3000 let contextStarted = false let serverStarted = false -for (const method of ['get', 'post', 'put', 'patch', 'delete', 'all']) { - const original = server[method].bind(server) - server[method] = function (...args) { - if (typeof args[1] === 'function' && args[1].name === '_invoke') { - return original(args[0], bodyParser.text({ limit: server.maximumPayloadSize }), async (request, response) => { - reqres.set(request, response) - const params = {} - for (const key of Object.keys(request.params)) { - params[key] = extractParamValue(request.params[key]) - } - for (const key of Object.keys(request.query)) { - params[key] = extractParamValue(request.query[key]) - } - if (request.method !== 'GET') { - const payload = typeof request.body === 'object' ? JSON.stringify(request.body) : request.body - Object.assign(params, deserialize(payload)) - } - try { - const subcontext = generateContext({ request, response, ...params }) - const result = await args[1](subcontext) - reqres.clear() - response.json(result) - } catch (error) { - printError(error) - reqres.clear() - response.status(500).json({}) - } - }) - } - return original(...args) - } -} - server.use(async (request, response, next) => { if (!contextStarted) { typeof context.start === 'function' && (await context.start()) @@ -76,87 +34,18 @@ server.use(async (request, response, next) => { next() }) -function createRequest(url) { - return { - method: 'GET', - host: '', - cookies: {}, - query: {}, - url, - headers: {}, - } -} +emulatePrerender(server) +exposeServerFunctions(server) -function createResponse(callback) { - const res = { - _removedHeader: {}, - _statusCode: 200, - statusMessage: 'OK', - get statusCode() { - return this._statusCode - }, - set statusCode(status) { - this._statusCode = status - this.status(status) - }, - } - const headers = {} - let code = 200 - res.set = res.header = (x, y) => { - if (arguments.length === 2) { - res.setHeader(x, y) - } else { - for (const key in x) { - res.setHeader(key, x[key]) - } - } - return res - } - res.setHeader = (x, y) => { - headers[x] = y - headers[x.toLowerCase()] = y - return res - } - res.getHeader = (x) => headers[x] - res.redirect = function (_code, url) { - if (typeof _code !== 'number') { - code = 301 - url = _code - } else { - code = _code - } - res.setHeader('Location', url) - res.end() - } - res.status = res.sendStatus = function (number) { - code = number - return res - } - res.end = - res.send = - res.write = - function (data = '') { - if (callback) callback(code, data, headers) - } - return res -} - -server.prerender = async function (originalUrl) { - server.start() - return new Promise((resolve) => { - server._router.handle( - createRequest(originalUrl), - createResponse((code, data) => resolve(data)), - () => {}, - ) - }) +if (module.hot) { + hmr(server) } server.start = function () { if (serverStarted) return serverStarted = true - server.use(express.static(path.join(__dirname, '..', 'public'))) + server.use(express.static(path.join(process.cwd(), 'public'))) server.use(bodyParser.text({ limit: server.maximumPayloadSize })) @@ -167,47 +56,50 @@ server.start = function () { response.send(generateFile(`${request.params.number}.client.js`, server)) }) - server.get(`/:number.client.js.map`, (request, response) => { - response.setHeader('Cache-Control', 'max-age=31536000, immutable') - response.contentType('application/json') - response.send(generateFile(`${request.params.number}.client.js.map`, server)) - }) - server.get(`/:number.client.css`, (request, response) => { response.setHeader('Cache-Control', 'max-age=31536000, immutable') response.contentType('text/css') response.send(generateFile(`${request.params.number}.client.css`, server)) }) - server.get(`/:number.client.css.map`, (request, response) => { - response.setHeader('Cache-Control', 'max-age=31536000, immutable') - response.contentType('application/json') - response.send(generateFile(`${request.params.number}.client.css.map`, server)) - }) - server.get(`/client.css`, (request, response) => { response.setHeader('Cache-Control', 'max-age=31536000, immutable') response.contentType('text/css') response.send(generateFile('client.css', server)) }) - server.get(`/client.css.map`, (request, response) => { - response.setHeader('Cache-Control', 'max-age=31536000, immutable') - response.contentType('application/json') - response.send(generateFile('client.css.map', server)) - }) - server.get(`/client.js`, (request, response) => { response.setHeader('Cache-Control', 'max-age=31536000, immutable') response.contentType('text/javascript') response.send(generateFile('client.js', server)) }) + } + + if (environment.development) { server.get(`/client.js.map`, (request, response) => { response.setHeader('Cache-Control', 'max-age=31536000, immutable') response.contentType('application/json') response.send(generateFile('client.js.map', server)) }) + + server.get(`/:number.client.js.map`, (request, response) => { + response.setHeader('Cache-Control', 'max-age=31536000, immutable') + response.contentType('application/json') + response.send(generateFile(`${request.params.number}.client.js.map`, server)) + }) + + server.get(`/client.css.map`, (request, response) => { + response.setHeader('Cache-Control', 'max-age=31536000, immutable') + response.contentType('application/json') + response.send(generateFile('client.css.map', server)) + }) + + server.get(`/:number.client.css.map`, (request, response) => { + response.setHeader('Cache-Control', 'max-age=31536000, immutable') + response.contentType('application/json') + response.send(generateFile(`${request.params.number}.client.css.map`, server)) + }) } server.get(`/manifest.webmanifest`, (request, response) => { @@ -233,6 +125,7 @@ server.start = function () { const { hash, methodName } = request.params const [invokerHash, boundHash] = hash.split('-') const key = `${invokerHash}.${methodName}` + await load(boundHash || invokerHash) const invokerKlass = registry[invokerHash] let boundKlass = invokerKlass if (boundHash) { @@ -259,6 +152,60 @@ server.start = function () { } }) + if (module.hot) { + server.all(`/${prefix}/:version/:hash/:methodName.json`, async (request, response) => { + const payload = request.method === 'GET' ? request.query.payload : request.body + reqres.set(request, response) + const args = deserialize(payload) + const { version, hash, methodName } = request.params + const [invokerHash, boundHash] = hash.split('-') + let [filePath, klassName] = (invokerHash || boundHash).split("___") + const file = path.resolve('..', filePath.replaceAll('__', '/')) + console.info('\x1b[1;37m%s\x1b[0m', ` [${request.method}] ${request.path}`) + console.info('\x1b[2;37m%s\x1b[0m', ` - ${file}`) + console.info('\x1b[2;37m%s\x1b[0m', ` - ${klassName}.${methodName}(${JSON.stringify(args)})\n`) + const key = `${invokerHash}.${methodName}` + let invokerKlass; + await load(boundHash || invokerHash) + async function reply() { + let boundKlass = invokerKlass + if (boundHash) { + boundKlass = registry[boundHash] + if (!(boundKlass.prototype instanceof invokerKlass)) { + return response.status(401).json({}) + } + } + const method = registry[key] + if (method !== undefined) { + try { + const subcontext = generateContext({ request, response, ...args }) + const result = await method.call(boundKlass, subcontext) + reqres.clear() + response.json({ result }) + } catch (error) { + printError(error) + reqres.clear() + response.status(500).json({}) + } + } else { + reqres.clear() + response.status(404).json({}) + } + } + async function delay() { + invokerKlass = registry[invokerHash] + if (invokerKlass && invokerKlass.__hashes[methodName] !== version) { + setTimeout(() => { + delay() + }, 0) + } else { + reply() + } + } + delay() + }) + } + server.get('*', async (request, response, next) => { if (request.originalUrl.split('?')[0].indexOf('.') > -1) { return next() @@ -280,24 +227,13 @@ server.start = function () { response.status(500).json({}) }) - if (!server.less) { - if (!server.port) { - console.info('\x1b[31mServer port is not defined!\x1b[0m') - process.exit() - } + if (module.hot) { + process.env.__NULLSTACK_FIRST_LOAD_COMPLETE = true + } + if (!server.less) { server.listen(server.port, async () => { - if (environment.development) { - if (process.env.NULLSTACK_ENVIRONMENT_DISK === 'true') { - const content = await server.prerender('/') - const target = `${process.cwd()}/.development/index.html` - writeFileSync(target, content) - } - const socket = new WebSocket(`ws://localhost:${process.env.NULLSTACK_SERVER_PORT}/ws`) - socket.onopen = async function () { - socket.send('{"type":"NULLSTACK_SERVER_STARTED"}') - } - } else { + if (environment.production) { console.info( '\x1b[36m%s\x1b[0m', ` βœ…οΈ Your application is ready at http://${process.env.NULLSTACK_PROJECT_DOMAIN}:${process.env.NULLSTACK_SERVER_PORT}\n`, diff --git a/server/template.js b/server/template.js index 22f6eb3b..8d9277b7 100644 --- a/server/template.js +++ b/server/template.js @@ -39,20 +39,20 @@ export default function ({ head, body, nextBody, context, instances }) { context: environment.mode === 'spa' ? {} : serializableContext, } return ` - + - ${page.title ? `${page.title}` : ''} + ${page.title || ''} - ${page.description ? `` : ''} - ${page.description ? `` : ''} - ${page.title ? `` : ''} + + + ${project.type ? `` : ''} ${project.name ? `` : ''} - ${page.locale ? `` : ''} + diff --git a/server/worker.js b/server/worker.js index c321fd51..662122ba 100644 --- a/server/worker.js +++ b/server/worker.js @@ -53,13 +53,13 @@ export function generateServiceWorker() { const sources = [] const context = { environment, project, settings, worker } let original = '' - const file = path.join(__dirname, '../', 'public', 'service-worker.js') + const file = path.join(process.cwd(), 'public', 'service-worker.js') if (existsSync(file)) { original = readFileSync(file, 'utf-8') } - const bundleFolder = path.join(__dirname, '../', environment.production ? '.production' : '.development') + const bundleFolder = path.join(process.cwd(), environment.production ? '.production' : '.development') const scripts = readdirSync(bundleFolder) - .filter((filename) => filename.includes('.client.')) + .filter((filename) => filename.includes('.client.') && !filename.endsWith('.map')) .map((filename) => `'/${filename}'`) sources.push(`self.context = ${JSON.stringify(context, replacer, 2)};`) sources.push(load) @@ -87,7 +87,7 @@ export function generateServiceWorker() { if (original) { sources.push(original) } - files['service-worker.js'] = sources.join(`\n\n`).replace(`"{{BUNDLE}}",`, scripts.join(', \n')) + files['service-worker.js'] = sources.join(`\n\n`).replace(`globalThis.__NULLSTACK_BUNDLE`, scripts.join(', \n')) return files['service-worker.js'] } diff --git a/shared/accept.js b/shared/accept.js new file mode 100644 index 00000000..e4316a4e --- /dev/null +++ b/shared/accept.js @@ -0,0 +1,3 @@ +if (module.hot) { + module.hot.accept() +} diff --git a/shared/generateTree.js b/shared/generateTree.js index efb59128..c084133a 100644 --- a/shared/generateTree.js +++ b/shared/generateTree.js @@ -25,9 +25,29 @@ async function generateBranch(siblings, node, depth, scope) { return } + if (module.hot && node.type?.__nullstack_lazy !== undefined) { + if (node.type.component) { + node.type = node.type.component + } else { + node.type.load(); + siblings.push({ + type: false, + attributes: {}, + }) + return + } + } + if (isClass(node)) { const key = generateKey(scope, node, depth) - const instance = scope.instances[key] || new node.type(scope) + let instance = scope.instances[key] + if (!instance) { + if (module.hot && node.type.hash) { + instance = new scope.klasses[node.type.hash](scope) + } else { + instance = new node.type(scope) + } + } instance.persistent = !!node.attributes.persistent instance.key = key instance._attributes = node.attributes diff --git a/shared/sanitizeString.js b/shared/sanitizeString.js index d72f66a2..ba031cc6 100644 --- a/shared/sanitizeString.js +++ b/shared/sanitizeString.js @@ -6,3 +6,8 @@ export function sanitizeHtml(unsafe) { export function sanitizeString(unsafe) { return unsafe.replace(/<\//g, `<\\\/`) } + +export function sanitizeInnerHtml(unsafe) { + if (unsafe === undefined || typeof(unsafe) !== 'string') return '' + return unsafe.replaceAll('<\\', '<') +} \ No newline at end of file diff --git a/tests/.env.test b/tests/.env.test index 76c1656d..8a2189e6 100644 --- a/tests/.env.test +++ b/tests/.env.test @@ -6,4 +6,4 @@ NULLSTACK_PROJECT_NAME='Nullstack Tests' NULLSTACK_PROJECT_SHORT_NAME='Nullstack' NULLSTACK_PROJECT_DOMAIN='localhost' NULLSTACK_WORKER_CDN='http://localhost:6969' -NULLSTACK_WORKER_API='http://127.0.0.1:6970' \ No newline at end of file +NULLSTACK_WORKER_API='http://127.0.0.1:6969' \ No newline at end of file diff --git a/tests/debug.njs b/tests/debug.njs new file mode 100644 index 00000000..019dc15a --- /dev/null +++ b/tests/debug.njs @@ -0,0 +1,129 @@ +import $runtime from "nullstack/runtime"; // server function works +import Nullstack from "nullstack"; +import { readFileSync } from "fs"; +const decodedString = "! * ' ( ) ; : @ & = + $ , / ? % # [ ]"; +class ServerFunctions extends Nullstack { + static hash = "3f6b569ca5fd55ed"; + count = 0; + year = null; + statement = ""; + response = ""; + clientOnly = ""; + static async getCountAsOne() { + return 1; + } + async setCountToOne() { + this.count = await this.getCountAsOne(); + } + static async getCount({ to }) { + return to; + } + async setCountToTwo() { + this.count = await this.getCount({ + to: 2 + }); + } + static async getDate({ input }) { + return input; + } + async setDate() { + const input = new Date("1992-10-16"); + const output = await this.getDate({ + input + }); + this.year = output.getFullYear(); + } + static async useNodeFileSystem() { + const text = readFileSync("src/ServerFunctions.njs", "utf-8"); + return text.split(`\n`)[0].trim(); + } + static async useFetchInNode() { + const response = await fetch("http://localhost:6969/robots.txt"); + const text = await response.text(); + return text.split(`\n`)[0].trim(); + } + static async getDoublePlusOne({ number }) { + return number * 2 + 1; + } + static async getEncodedString({ string }) { + return string === decodedString; + } + static async _privateFunction() { + return true; + } + static async getPrivateFunction({ request }) { + return this._privateFunction(); + } + static async getRequestUrl({ request }) { + return request.originalUrl.startsWith("/"); + } + async initiate() { + this.statement = await this.useNodeFileSystem(); + this.response = await this.useFetchInNode(); + this.doublePlusOneServer = await ServerFunctions.getDoublePlusOne({ + number: 34 + }); + this.originalUrl = await ServerFunctions.getRequestUrl(); + } + async hydrate() { + this.underlineRemovedFromClient = !ServerFunctions._privateFunction; + this.underlineStayOnServer = await ServerFunctions.getPrivateFunction(); + this.doublePlusOneClient = await ServerFunctions.getDoublePlusOne({ + number: 34 + }); + this.acceptsSpecialCharacters = await this.getEncodedString({ + string: decodedString + }); + this.hydratedOriginalUrl = await ServerFunctions.getRequestUrl(); + } + render() { + return /*#__PURE__*/ $runtime.element("div", { + "data-hydrated": this.hydrated + }, /*#__PURE__*/ $runtime.element("button", { + class: "set-count-to-one", + onclick: this.setCountToOne, + source: this + }, "1"), /*#__PURE__*/ $runtime.element("button", { + class: "set-count-to-two", + onclick: this.setCountToTwo, + source: this + }, "2"), /*#__PURE__*/ $runtime.element("button", { + class: "set-date", + onclick: this.setDate, + source: this + }, "1992"), /*#__PURE__*/ $runtime.element("div", { + "data-count": this.count + }), /*#__PURE__*/ $runtime.element("div", { + "data-year": this.year + }), /*#__PURE__*/ $runtime.element("div", { + "data-statement": this.statement + }), /*#__PURE__*/ $runtime.element("div", { + "data-response": this.response + }), /*#__PURE__*/ $runtime.element("div", { + "data-double-plus-one-server": this.doublePlusOneServer === 69 + }), /*#__PURE__*/ $runtime.element("div", { + "data-double-plus-one-client": this.doublePlusOneClient === 69 + }), /*#__PURE__*/ $runtime.element("div", { + "data-accepts-special-characters": this.acceptsSpecialCharacters + }), /*#__PURE__*/ $runtime.element("div", { + "data-underline-removed-from-client": this.underlineRemovedFromClient + }), /*#__PURE__*/ $runtime.element("div", { + "data-underline-stay-on-server": this.underlineStayOnServer + }), /*#__PURE__*/ $runtime.element("div", { + "data-hydrated-original-url": this.hydratedOriginalUrl + }), /*#__PURE__*/ $runtime.element("div", { + "data-original-url": this.originalUrl + })); + } +} +export default ServerFunctions; +$runtime.register(ServerFunctions, "getCountAsOne"); +$runtime.register(ServerFunctions, "getCount"); +$runtime.register(ServerFunctions, "getDate"); +$runtime.register(ServerFunctions, "useNodeFileSystem"); +$runtime.register(ServerFunctions, "useFetchInNode"); +$runtime.register(ServerFunctions, "getDoublePlusOne"); +$runtime.register(ServerFunctions, "getEncodedString"); +$runtime.register(ServerFunctions, "getPrivateFunction"); +$runtime.register(ServerFunctions, "getRequestUrl"); +$runtime.register(ServerFunctions); diff --git a/tests/package.json b/tests/package.json index 219dc4df..773810ac 100644 --- a/tests/package.json +++ b/tests/package.json @@ -5,24 +5,29 @@ "author": "", "license": "ISC", "devDependencies": { + "@swc/core": "1.3.35", "cors": "2.8.5", + "eslint-plugin-jest": "^27.1.6", "glob": "^8.0.3", - "jest": "^28.1.0", - "jest-puppeteer": "^6.1.0", - "nullstack": "*", - "puppeteer": "^14.1.1", - "purgecss-webpack-plugin": "^4.1.3" + "jest": "29.4.1", + "jest-puppeteer": "7.0.0", + "puppeteer": "19.6.3", + "purgecss-webpack-plugin": "5.0.0" }, "types": "../../types/index.d.ts", + "overrides": { + "webpack": "^5.0.0", + "terser": "npm:@swc/core" + }, + "resolutions": { + "terser": "npm:@swc/core" + }, "scripts": { - "start": "npx nullstack start --input=./tests --port=6969 --env=test --mode=spa", - "build": "npx nullstack build --input=./tests --env=test", + "start": "node ../scripts/index.js start --port=6969 --name=test --disk", + "build": "node --enable-source-maps ../scripts/index.js build --name=test", "clear": "rm -rf ../node_modules ../package-lock.json node_modules .development .production package-lock.json", - "setup": "cd .. && npm install && npm link && cd tests && npm install && npm link nullstack", + "setup": "cd .. && npm install && cd tests && npm install", "test": "npm run build && jest --runInBand", "script": "node src/scripts/run.js" - }, - "dependencies": { - "eslint-plugin-nullstack": "^0.0.3" } } \ No newline at end of file diff --git a/tests/server.js b/tests/server.js index e5428264..345659d1 100644 --- a/tests/server.js +++ b/tests/server.js @@ -10,6 +10,7 @@ import ContextSecrets from './src/ContextSecrets' import ContextSettings from './src/ContextSettings' import ContextWorker from './src/ContextWorker' import ExposedServerFunctions from './src/ExposedServerFunctions' +import setExternalRoute from './src/externalRoute' import vueable from './src/plugins/vueable' import ReqRes from './src/ReqRes' @@ -70,6 +71,8 @@ context.server.get('/vaidamerdanaapi.json', (_request, response) => { context.startIncrementalValue = 0 +setExternalRoute(context.server) + context.start = async function () { await ContextProject.start(context) await ContextSecrets.start(context) diff --git a/tests/src/Application.css b/tests/src/Application.css index 789a3ef9..52abc363 100644 --- a/tests/src/Application.css +++ b/tests/src/Application.css @@ -1,5 +1,5 @@ body { - background: url(favicon-96x96.png) no-repeat bottom right; + background: url(/favicon-96x96.png) no-repeat bottom right; } #application * { diff --git a/tests/src/Application.njs b/tests/src/Application.njs index 997742df..f79960d4 100644 --- a/tests/src/Application.njs +++ b/tests/src/Application.njs @@ -1,7 +1,6 @@ import Nullstack from 'nullstack' import AnchorModifiers from './AnchorModifiers' -import './Application.css' import ArrayAttributes from './ArrayAttributes' import BodyFragment from './BodyFragment' import CatchError from './CatchError' @@ -27,17 +26,17 @@ import FullStackLifecycle from './FullStackLifecycle' import Instanceable from './Instanceable' import InstanceKey from './InstanceKey' import InstanceSelf from './InstanceSelf' -import IsomorphicImport from './IsomorphicImport.njs' +import IsomorphicImport from './IsomorphicImport' import IsomorphicStartup from './IsomorphicStartup' import JavaScriptExtension from './JavaScriptExtension' -import LazyComponentLoader from './LazyComponentLoader' +import Logo from './Logo' import MetatagState from './MetatagState' import NestedProxy from './NestedProxy' import OptimizedEvents from './OptimizedEvents' import ParentComponent from './ParentComponent' import PersistentComponent from './PersistentComponent' import PluginAttributes from './PluginAttributes' -import PublicServerFunctions from './PublicServerFunctions.njs' +import PublicServerFunctions from './PublicServerFunctions' import PureComponents from './PureComponents' import Refs from './Refs' import RenderableComponent from './RenderableComponent' @@ -56,8 +55,12 @@ import UndefinedNodes from './UndefinedNodes' import UnderscoredAttributes from './UnderscoredAttributes' import Vunerability from './Vunerability' import WebpackCustomPlugin from './WebpackCustomPlugin' -import WindowDependency from './WindowDependency' import WorkerVerbs from './WorkerVerbs' +import LazyComponent from './LazyComponent' +import LazyComponentLoader from './LazyComponentLoader' +import NestedFolder from './nested/NestedFolder' +import ChildComponentWithoutServerFunctions from './ChildComponentWithoutServerFunctions' +import './Application.css' class Application extends Nullstack { @@ -72,10 +75,12 @@ class Application extends Nullstack { render({ project, page, environment, refInstanceCount }) { return ( - +

{project.name}

{page.status !== 200 &&
}
+ go lazy go home + import offline static this router with params @@ -141,6 +146,10 @@ class Application extends Nullstack { + + + + ) diff --git a/tests/src/Application.test.js b/tests/src/Application.test.js index 8a4dd3b4..bb563435 100644 --- a/tests/src/Application.test.js +++ b/tests/src/Application.test.js @@ -15,9 +15,4 @@ describe('Application', () => { const element = await page.$('[rel="stylesheet"]') expect(element).toBeTruthy() }) - - test('simple scripts depending on window should be importable on server', async () => { - const element = await page.$('[data-window="shim"]') - expect(element).toBeTruthy() - }) }) diff --git a/tests/src/BodyFragment.test.js b/tests/src/BodyFragment.test.js index b95624bf..6860906d 100644 --- a/tests/src/BodyFragment.test.js +++ b/tests/src/BodyFragment.test.js @@ -52,9 +52,9 @@ describe('BodyFragment', () => { test('the body removes events when the fragment leaves the tree', async () => { await page.waitForSelector('body[data-hydrated]') await page.click('[href="/"]') - await page.waitForSelector('[data-window="shim"]:not([data-count])') + await page.waitForSelector('[data-application-hydrated]:not([data-count])') await page.click('body') - const element = await page.$('[data-window="shim"]:not([data-count])') + const element = await page.$('[data-application-hydrated]:not([data-count])') expect(element).toBeTruthy() }) }) diff --git a/tests/src/ChildComponentWithoutServerFunctions.njs b/tests/src/ChildComponentWithoutServerFunctions.njs new file mode 100644 index 00000000..228fee9d --- /dev/null +++ b/tests/src/ChildComponentWithoutServerFunctions.njs @@ -0,0 +1,24 @@ +import ParentComponent from './ParentComponent' + +class ChildComponentWithoutServerFunctions extends ParentComponent { + + async initiate() { + this.parentThis = await this.getParentThis() + } + + async hydrate() { + this.hydratedParentThis = await this.getParentThis() + } + + render() { + return ( +
+
+
+
+ ) + } + +} + +export default ChildComponentWithoutServerFunctions \ No newline at end of file diff --git a/tests/src/ChildComponentWithoutServerFunctions.test.js b/tests/src/ChildComponentWithoutServerFunctions.test.js new file mode 100644 index 00000000..1c5a4873 --- /dev/null +++ b/tests/src/ChildComponentWithoutServerFunctions.test.js @@ -0,0 +1,16 @@ +beforeAll(async () => { + await page.goto('http://localhost:6969/child-component-without-server-functions') +}) + +describe('ChildComponentWithoutServerFunctions', () => { + test('inherited server functions are bound to the class ssr', async () => { + const element = await page.$('[data-parent-this]') + expect(element).toBeTruthy() + }) + + test('inherited server functions are bound to the class spa', async () => { + await page.waitForSelector('[data-hydrated-parent-this]') + const element = await page.$('[data-hydrated-parent-this]') + expect(element).toBeTruthy() + }) +}) diff --git a/tests/src/ContextPage.njs b/tests/src/ContextPage.njs index dc54321b..ad47668c 100644 --- a/tests/src/ContextPage.njs +++ b/tests/src/ContextPage.njs @@ -35,6 +35,13 @@ class ContextPage extends Nullstack { await this.raiseStatus({ status }) } + updateHead({ page }) { + page.title = 'Nullstack Tests Updated' + page.image = '/image-updated.jpg' + page.description = 'Nullstack tests page that tests the context page updated' + page.locale = 'en-US' + } + render({ page }) { return (
@@ -42,13 +49,11 @@ class ContextPage extends Nullstack {
- -
) diff --git a/tests/src/ContextPage.test.js b/tests/src/ContextPage.test.js index 43d71058..e1b89f63 100644 --- a/tests/src/ContextPage.test.js +++ b/tests/src/ContextPage.test.js @@ -1,8 +1,8 @@ -beforeAll(async () => { - await page.goto('http://localhost:6969/context-page') -}) - describe('ContextPage', () => { + beforeAll(async () => { + await page.goto('http://localhost:6969/context-page') + }) + test('is part of the client context', async () => { const element = await page.$('[data-page]') expect(element).toBeTruthy() @@ -73,17 +73,42 @@ describe('ContextPage', () => { expect(element).toBeTruthy() }) - test('a custom event is triggered when the title changes', async () => { - await page.click('button') - await page.waitForSelector('[data-event-triggered]') - const element = await page.$('[data-event-triggered]') - expect(element).toBeTruthy() - }) - test('the page reacts to a server function status', async () => { - await page.click('[status="401"]') + await page.click('[data-request-status]') await page.waitForSelector('[data-page-status="401"]') const element = await page.$('[data-page-status="401"]') expect(element).toBeTruthy() }) }) + +describe('ContextPage updated', () => { + beforeAll(async () => { + await page.goto('http://localhost:6969/context-page') + await page.waitForSelector('[data-application-hydrated]') + await page.click('[data-update-head]') + }) + + test('updates meta og:title', async () => { + await page.waitForSelector('meta[property="og:title"][content="Nullstack Tests Updated"]') + const element = await page.$('meta[property="og:title"][content="Nullstack Tests Updated"]') + expect(element).toBeTruthy() + }) + + test('updates meta og:description', async () => { + await page.waitForSelector('meta[property="og:description"][content="Nullstack tests page that tests the context page updated"]') + const element = await page.$('meta[property="og:description"][content="Nullstack tests page that tests the context page updated"]') + expect(element).toBeTruthy() + }) + + test('updates meta description', async () => { + await page.waitForSelector('meta[name="description"][content="Nullstack tests page that tests the context page updated"]') + const element = await page.$('meta[name="description"][content="Nullstack tests page that tests the context page updated"]') + expect(element).toBeTruthy() + }) + + test('a custom event is triggered when the title changes', async () => { + await page.waitForSelector('[data-event-triggered]') + const element = await page.$('[data-event-triggered]') + expect(element).toBeTruthy() + }) +}) \ No newline at end of file diff --git a/tests/src/ContextWorker.njs b/tests/src/ContextWorker.njs index 4a327eca..47786555 100644 --- a/tests/src/ContextWorker.njs +++ b/tests/src/ContextWorker.njs @@ -11,7 +11,6 @@ class ContextWorker extends Nullstack { header = ''; static async start({ worker }) { - worker.enabled = true worker.preload = ['/context-worker'] } diff --git a/tests/src/Element.njs b/tests/src/Element.njs index 15d72b74..f9497d30 100644 --- a/tests/src/Element.njs +++ b/tests/src/Element.njs @@ -29,7 +29,7 @@ class Element extends Nullstack { abbr - + diff --git a/tests/src/Element.test.js b/tests/src/Element.test.js index 83379ef3..401a79c4 100644 --- a/tests/src/Element.test.js +++ b/tests/src/Element.test.js @@ -2,7 +2,7 @@ beforeAll(async () => { await page.goto('http://localhost:6969/element') }) -describe('FullStackLifecycle', () => { +describe('Element', () => { test('elements can receive any tag b', async () => { const element = await page.$('b[data-tag="b"]') expect(element).toBeTruthy() diff --git a/tests/src/FullStackLifecycle.njs b/tests/src/FullStackLifecycle.njs index 57300ae7..34ad2d5c 100644 --- a/tests/src/FullStackLifecycle.njs +++ b/tests/src/FullStackLifecycle.njs @@ -39,7 +39,7 @@ class FullStackLifecycle extends Nullstack { render() { return ( -
+
diff --git a/tests/src/FullStackLifecycle.test.js b/tests/src/FullStackLifecycle.test.js index cbdd24e0..abf123d9 100644 --- a/tests/src/FullStackLifecycle.test.js +++ b/tests/src/FullStackLifecycle.test.js @@ -62,7 +62,9 @@ describe('FullStackLifecycle ssr', () => { describe('FullStackLifecycle spa', () => { beforeAll(async () => { await page.goto('http://localhost:6969/') + await page.waitForSelector('[data-application-hydrated]') await page.click('a[href="/full-stack-lifecycle"]') + await page.waitForSelector('[data-hydrated]') }) test('prepare should run', async () => { diff --git a/tests/src/LazyComponent.njs b/tests/src/LazyComponent.njs index 7ffb1a06..62b6605c 100644 --- a/tests/src/LazyComponent.njs +++ b/tests/src/LazyComponent.njs @@ -11,11 +11,12 @@ class LazyComponent extends Nullstack { this.safelisted = await this.serverFunctionWorks() } - render() { + render({ prop }) { return ( -
- {' '} - LazyComponent{' '} +
+

safelisted: {this.safelisted}

+

prop: {prop}

+ home
) } diff --git a/tests/src/LazyComponentLoader.njs b/tests/src/LazyComponentLoader.njs index 7ad55b99..57702b76 100644 --- a/tests/src/LazyComponentLoader.njs +++ b/tests/src/LazyComponentLoader.njs @@ -15,7 +15,7 @@ class LazyComponentLoader extends Nullstack { } render() { - if (!this.hydrated) return false + if (!LazyComponent) return false return } diff --git a/tests/src/Logo.njs b/tests/src/Logo.njs new file mode 100644 index 00000000..dfefe3d6 --- /dev/null +++ b/tests/src/Logo.njs @@ -0,0 +1,16 @@ +import Nullstack from 'nullstack'; +import NullstackLogo from 'nullstack/logo'; + +class Logo extends Nullstack { + + render() { + return ( +
+ +
+ ) + } + +} + +export default Logo; \ No newline at end of file diff --git a/tests/src/Logo.test.js b/tests/src/Logo.test.js new file mode 100644 index 00000000..d56d56c6 --- /dev/null +++ b/tests/src/Logo.test.js @@ -0,0 +1,10 @@ +beforeAll(async () => { + await page.goto('http://localhost:6969/logo') +}) + +describe('Logo', () => { + test('logo can be imported', async () => { + const element = await page.$('svg[viewBox="0 0 511.5039 113.7368"]') + expect(element).toBeTruthy() + }) +}) \ No newline at end of file diff --git a/tests/src/RenderableComponent.njs b/tests/src/RenderableComponent.njs index 59a00f25..4b8587b6 100644 --- a/tests/src/RenderableComponent.njs +++ b/tests/src/RenderableComponent.njs @@ -25,6 +25,10 @@ class RenderableComponent extends Nullstack { return false } + renderRepeated({ number }) { + return
+ } + render({ params }) { const list = params.shortList ? [1, 2, 3] : [1, 2, 3, 4, 5, 6] const html = ' Nullstack ' @@ -33,7 +37,7 @@ class RenderableComponent extends Nullstack {
-
+
this is a normal tag
@@ -43,7 +47,7 @@ class RenderableComponent extends Nullstack { element tag - + children
    @@ -65,13 +69,15 @@ class RenderableComponent extends Nullstack {
    - + +
    ) } } -export default RenderableComponent +export default RenderableComponent \ No newline at end of file diff --git a/tests/src/RenderableComponent.test.js b/tests/src/RenderableComponent.test.js index c2a63b50..c8d9a77d 100644 --- a/tests/src/RenderableComponent.test.js +++ b/tests/src/RenderableComponent.test.js @@ -87,6 +87,12 @@ describe('RenderableComponent', () => { const element = await page.$('[data-reference]') expect(element).toBeTruthy() }) + + test('inner components can be repeated', async () => { + const first = await page.$('[data-repeated="1"]') + const second = await page.$('[data-repeated="2"]') + expect(first && second).toBeTruthy() + }) }) describe('RenderableComponent ?condition=true', () => { diff --git a/tests/src/RoutesAndParams.test.js b/tests/src/RoutesAndParams.test.js index 26003cd9..5af07122 100644 --- a/tests/src/RoutesAndParams.test.js +++ b/tests/src/RoutesAndParams.test.js @@ -258,22 +258,26 @@ describe('RoutesAndParams /routes-and-params/inner-html', () => { describe('RoutesAndParams /routes-and-params/hrefs spa', () => { beforeEach(async () => { await page.goto('http://localhost:6969/routes-and-params/hrefs') + await page.waitForSelector(`[data-application-hydrated]`) }) test('https urls do a full redirect', async () => { - await Promise.all([page.click('[href="https://nullstack.app/"]'), page.waitForNavigation()]) + await page.click('[href="https://nullstack.app/"]') + await page.waitForSelector('link[rel="canonical"][href="https://nullstack.app/"]') const url = await page.url() expect(url).toMatch('://nullstack.app') }) test('http urls do a full redirect', async () => { - await Promise.all([page.click('[href="http://nullstack.app/"]'), page.waitForNavigation()]) + await page.click('[href="http://nullstack.app/"]') + await page.waitForSelector('link[rel="canonical"][href="https://nullstack.app/"]') const url = await page.url() expect(url).toMatch('://nullstack.app') }) test('// urls do a full redirect', async () => { - await Promise.all([page.click('[href="//nullstack.app/"]'), page.waitForNavigation()]) + await page.click('[href="//nullstack.app/"]') + await page.waitForSelector('link[rel="canonical"][href="https://nullstack.app/"]') const url = await page.url() expect(url).toMatch('://nullstack.app') }) diff --git a/tests/src/ServerFunctions.njs b/tests/src/ServerFunctions.njs index f526116d..d5081cce 100644 --- a/tests/src/ServerFunctions.njs +++ b/tests/src/ServerFunctions.njs @@ -62,6 +62,10 @@ class ServerFunctions extends Nullstack { return true } + static async getPrivateFunction({ request }) { + return this._privateFunction() + } + static async getRequestUrl({ request }) { return request.originalUrl.startsWith('/') } @@ -75,6 +79,7 @@ class ServerFunctions extends Nullstack { async hydrate() { this.underlineRemovedFromClient = !ServerFunctions._privateFunction + this.underlineStayOnServer = await ServerFunctions.getPrivateFunction() this.doublePlusOneClient = await ServerFunctions.getDoublePlusOne({ number: 34 }) this.acceptsSpecialCharacters = await this.getEncodedString({ string: decodedString }) this.hydratedOriginalUrl = await ServerFunctions.getRequestUrl() @@ -100,6 +105,7 @@ class ServerFunctions extends Nullstack {
    +
    diff --git a/tests/src/ServerFunctions.test.js b/tests/src/ServerFunctions.test.js index 329902a5..32e28dba 100644 --- a/tests/src/ServerFunctions.test.js +++ b/tests/src/ServerFunctions.test.js @@ -63,6 +63,12 @@ describe('ServerFunctions', () => { expect(element).toBeTruthy() }) + test('server functions starting with underline stay on server', async () => { + await page.waitForSelector('[data-underline-stay-on-server]') + const element = await page.$('[data-underline-stay-on-server]') + expect(element).toBeTruthy() + }) + test('static server functions receive the context on spa', async () => { await page.waitForSelector('[data-hydrated-original-url]') const element = await page.$('[data-hydrated-original-url]') diff --git a/tests/src/WindowDependency.js b/tests/src/WindowDependency.js deleted file mode 100644 index b1806955..00000000 --- a/tests/src/WindowDependency.js +++ /dev/null @@ -1,3 +0,0 @@ -window.key = 'shim' - -export default window diff --git a/tests/src/externalRoute.js b/tests/src/externalRoute.js new file mode 100644 index 00000000..f9d25d97 --- /dev/null +++ b/tests/src/externalRoute.js @@ -0,0 +1,8 @@ +export default function setExternalRoute(server) { + server.get('/external-route.json', (_request, response) => { + response.json({ nice: 69 }) + }) + server.get('/external-route.json', (_request, response) => { + response.json({ nicent: 68 }) + }) +} diff --git a/tests/src/externalRoute.test.js b/tests/src/externalRoute.test.js new file mode 100644 index 00000000..48d1fb77 --- /dev/null +++ b/tests/src/externalRoute.test.js @@ -0,0 +1,12 @@ +beforeAll(async () => { + await page.goto('http://localhost:6969/external-route.json') +}) + +describe('ExtermalRoute', () => { + test('express functions keep their priority', async () => { + const response = await page.evaluate(() => { + return JSON.parse(document.querySelector('body').innerText) + }) + expect(response.nice === 69).toBeTruthy() + }) +}) diff --git a/tests/src/nested/NestedFolder.njs b/tests/src/nested/NestedFolder.njs new file mode 100644 index 00000000..fac716cc --- /dev/null +++ b/tests/src/nested/NestedFolder.njs @@ -0,0 +1,17 @@ +import Nullstack from 'nullstack' +import LazyComponent from '../LazyComponent' + +class NestedFolder extends Nullstack { + + render() { + return ( +
    + NestedFolder + +
    + ) + } + +} + +export default NestedFolder; \ No newline at end of file diff --git a/tests/webpack.config.js b/tests/webpack.config.js index dcbc7a72..f399c2d5 100644 --- a/tests/webpack.config.js +++ b/tests/webpack.config.js @@ -1,22 +1,27 @@ -const [server, client] = require('nullstack/webpack.config') - const glob = require('glob') -const PurgecssPlugin = require('purgecss-webpack-plugin') +const path = require('path') +const { PurgeCSSPlugin } = require('purgecss-webpack-plugin') -function customClient(...args) { - const config = client(...args) - if (config.mode === 'production') { - config.plugins.push( - new PurgecssPlugin({ - paths: glob.sync(`src/**/*`, { nodir: true }), - content: ['./**/*.njs'], - safelist: ['script', 'body', 'html', 'style'], - defaultExtractor: (content) => content.match(/[^<>"'`\s]*[^<>"'`\s:]/g) || [], - }), - ) - } +const [server, client] = require('../webpack.config') - return config +function applyAliases(environments) { + return environments.map((environment) => (...args) => { + const config = environment(...args) + config.resolve.alias._ = path.join(process.cwd(), '..', 'node_modules'); + config.resolve.alias["terser"] = path.join(process.cwd(), '..', 'node_modules', '@swc/core'); + config.resolve.alias.webpack = path.join(process.cwd(), '..', 'node_modules', 'webpack'); + if (config.mode === 'production' && config.target === 'web') { + config.plugins.push( + new PurgeCSSPlugin({ + paths: glob.sync(`src/**/*`, { nodir: true }), + content: ['./**/*.njs'], + safelist: ['script', 'body', 'html', 'style'], + defaultExtractor: (content) => content.match(/[^<>"'`\s]*[^<>"'`\s:]/g) || [], + }), + ) + } + return config + }) } -module.exports = [server, customClient] +module.exports = applyAliases([server, client]) diff --git a/types/Environment.d.ts b/types/Environment.d.ts index 70bebfe5..db962150 100644 --- a/types/Environment.d.ts +++ b/types/Environment.d.ts @@ -24,7 +24,5 @@ export type NullstackEnvironment = { */ event: string - hot?: boolean - disk?: boolean } diff --git a/webpack.config.js b/webpack.config.js index a6cd8aab..4904a02f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,465 +1,57 @@ -const CopyPlugin = require('copy-webpack-plugin') -const crypto = require('crypto') -const { existsSync, readdirSync } = require('fs') -const MiniCssExtractPlugin = require('mini-css-extract-plugin') -const NodemonPlugin = require('nodemon-webpack-plugin') +const { existsSync } = require('fs') const path = require('path') -const TerserPlugin = require('terser-webpack-plugin') -const buildKey = crypto.randomBytes(20).toString('hex') - -const customConsole = new Proxy( - {}, - { - get() { - return () => {} - }, - }, -) - -function getLoader(loader) { - const loaders = path.resolve('./node_modules/nullstack/loaders') - return path.join(loaders, loader) -} - -function cacheFactory(args, folder, name) { - if (args.cache || args.environment === 'development') { - return { - type: 'filesystem', - cacheDirectory: path.resolve(`./${folder}/.cache`), - name, - } +function getOptions(target, options) { + const disk = !!options.disk; + const environment = options.environment + const entry = existsSync(path.posix.join(process.cwd(), `${target}.ts`)) ? `./${target}.ts` : `./${target}.js` + const projectFolder = process.cwd() + const configFolder = __dirname + const buildFolder = '.' + environment + const cache = !options.skipCache + const name = options.name || '' + const trace = !!options.trace + return { + target, + disk, + buildFolder, + entry, + environment, + cache, + name, + trace, + projectFolder, + configFolder } - return false -} - -function terserMinimizer(file, _sourceMap) { - return require('@swc/core').minify(file, { - keepClassnames: true, - keepFnames: true, - sourceMap: true, - }) -} - -const swcJs = { - test: /\.js$/, - use: { - loader: require.resolve('swc-loader'), - options: { - jsc: { - parser: { - syntax: 'ecmascript', - exportDefaultFrom: true, - }, - }, - env: { - targets: { node: '10' }, - }, - }, - }, -} - -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: { - loader: require.resolve('swc-loader'), - options: { - jsc: { - parser: { - syntax: 'typescript', - exportDefaultFrom: true, - }, - }, - env: { - targets: { node: '10' }, - }, - }, - }, -} - -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'), - options: { - jsc: { - parser: { - syntax: 'ecmascript', - exportDefaultFrom: true, - jsx: true, - }, - transform: { - react: { - pragma: 'Nullstack.element', - pragmaFrag: 'Nullstack.fragment', - throwIfNamespace: true, - }, - }, - }, - env: { - targets: { node: '10' }, - }, - }, - }, -} - -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'), - options: { - jsc: { - parser: { - syntax: 'typescript', - exportDefaultFrom: true, - tsx: true, - }, - transform: { - react: { - pragma: 'Nullstack.element', - pragmaFrag: 'Nullstack.fragment', - throwIfNamespace: true, - }, - }, - }, - env: { - targets: { node: '10' }, - }, - }, - }, -} - -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.loader === 'babel' - const iconFileRegex = /icon-(\d+)x\1\.[a-zA-Z]+/ - for (const file of publicFiles) { - if (iconFileRegex.test(file)) { - const size = file.split('x')[1].split('.')[0] - icons[size] = `/${file}` - } - } - const isDev = argv.environment === 'development' - const folder = isDev ? '.development' : '.production' - const devtool = isDev ? 'inline-cheap-module-source-map' : 'source-map' - const minimize = !isDev - const plugins = [] - if (isDev) { - plugins.push( - new NodemonPlugin({ - ext: '*', - watch: ['.env', '.env.*', './.development/server.js'], - script: './.development/server.js', - nodeArgs: ['--enable-source-maps'], - quiet: true, - }), - ) - } +function config(platform, argv) { + const options = getOptions(platform, argv); return { - mode: argv.environment, - infrastructureLogging: { - console: customConsole, - }, - entry: `./server.${entryExtension}`, - output: { - path: path.join(dir, folder), - filename: 'server.js', - chunkFilename: '[chunkhash].server.js', - libraryTarget: 'umd', - }, - resolve: { - extensions: ['.njs', '.js', '.nts', '.ts', '.tsx', '.jsx'], - }, - optimization: { - minimize, - minimizer: [ - new TerserPlugin({ - minify: terserMinimizer, - // workaround: disable parallel to allow caching server - parallel: argv.cache ? false : require('os').cpus().length - 1, - }), - ], - }, - devtool, - stats: 'errors-only', - module: { - rules: [ - { - test: /nullstack.js$/, - loader: getLoader('string-replace.js'), - options: { - multiple: [ - { - search: /{{NULLSTACK_ENVIRONMENT_NAME}}/gi, - replace: 'server', - }, - ], - }, - }, - { - test: /environment.js$/, - loader: getLoader('string-replace.js'), - options: { - multiple: [ - { - search: /{{NULLSTACK_ENVIRONMENT_KEY}}/gi, - replace: buildKey, - }, - ], - }, - }, - { - test: /project.js$/, - loader: getLoader('string-replace.js'), - options: { - multiple: [ - { - search: /{{NULLSTACK_PROJECT_ICONS}}/gi, - replace: JSON.stringify(icons), - }, - ], - }, - }, - babel ? babelJs : swcJs, - babel ? babelTs : swcTs, - babel ? babelNullstackJavascript : swcNullstackJavascript, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('register-static-from-server.js'), - }, - { - test: /\.s?[ac]ss$/, - use: [{ loader: getLoader('ignore-import.js') }], - }, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('register-inner-components.js'), - }, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('inject-nullstack.js'), - }, - babel ? babelNullstackTypescript : swcNullstackTypescript, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('add-source-to-node.js'), - }, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('transform-node-ref.js'), - }, - { - issuer: /worker.js/, - resourceQuery: /raw/, - type: 'asset/source', - }, - ], - }, - target: 'node', - node: { - __dirname: false, - __filename: false, - }, - plugins, - cache: cacheFactory(argv, folder, 'server'), + mode: require('./webpack/mode')(options), + infrastructureLogging: require('./webpack/infrastructureLogging')(options), + entry: require('./webpack/entry')(options), + output: require('./webpack/output')(options), + resolve: require('./webpack/resolve')(options), + optimization: require('./webpack/optimization')(options), + devtool: require('./webpack/devtool')(options), + stats: require('./webpack/stats')(options), + target: require('./webpack/target')(options), + externals: require('./webpack/externals')(options), + node: require('./webpack/node')(options), + cache: require('./webpack/cache')(options), + module: require('./webpack/module')(options), + plugins: require('./webpack/plugins')(options), + experiments: require('./webpack/experiments')(options), } } -function client(env, argv) { - const disk = !!argv.disk - const dir = argv.input ? path.join(__dirname, argv.input) : process.cwd() - const entryExtension = existsSync(path.join(dir, 'client.ts')) ? 'ts' : 'js' - const isDev = argv.environment === 'development' - const folder = isDev ? '.development' : '.production' - const devtool = isDev ? 'inline-cheap-module-source-map' : 'source-map' - const minimize = !isDev - const babel = argv.loader === 'babel' - const plugins = [ - new MiniCssExtractPlugin({ - filename: 'client.css', - chunkFilename: '[chunkhash].client.css', - }), - ] - if (disk) { - plugins.push( - new CopyPlugin({ - patterns: [{ from: 'public', to: '../.development' }], - }), - ) - } - return { - infrastructureLogging: { - console: customConsole, - }, - mode: argv.environment, - entry: `./client.${entryExtension}`, - output: { - publicPath: `/`, - path: path.join(dir, folder), - filename: 'client.js', - chunkFilename: '[chunkhash].client.js', - }, - resolve: { - extensions: ['.njs', '.js', '.nts', '.ts', '.tsx', '.jsx'], - }, - optimization: { - minimize, - minimizer: [ - new TerserPlugin({ - minify: terserMinimizer, - }), - ], - }, - devtool, - stats: 'errors-only', - module: { - rules: [ - { - test: /client\.(js|ts)$/, - loader: getLoader('inject-hmr.js'), - }, - { - test: /nullstack.js$/, - loader: getLoader('string-replace.js'), - options: { - multiple: [ - { - search: /{{NULLSTACK_ENVIRONMENT_NAME}}/gi, - replace: 'client', - }, - ], - }, - }, - babel ? babelJs : swcJs, - babel ? babelTs : swcTs, - babel ? babelNullstackJavascript : swcNullstackJavascript, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('remove-import-from-client.js'), - }, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('remove-static-from-client.js'), - }, - { - test: /\.s?[ac]ss$/, - use: [ - MiniCssExtractPlugin.loader, - { loader: require.resolve('css-loader'), options: { url: false } }, - { loader: require.resolve('sass-loader'), options: { sassOptions: { fibers: false } } }, - ], - }, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('register-inner-components.js'), - }, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('inject-nullstack.js'), - }, - babel ? babelNullstackTypescript : swcNullstackTypescript, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('add-source-to-node.js'), - }, - { - test: /\.(njs|nts|jsx|tsx)$/, - loader: getLoader('transform-node-ref.js'), - }, - ], - }, - target: 'web', - plugins, - cache: cacheFactory(argv, folder, 'client'), - } +function server(_env, argv) { + return config('server', argv) +} + +function client(_env, argv) { + return config('client', argv) } -module.exports = [server, client] +module.exports = [server, client] \ No newline at end of file diff --git a/webpack/cache.js b/webpack/cache.js new file mode 100644 index 00000000..2c0ac1f8 --- /dev/null +++ b/webpack/cache.js @@ -0,0 +1,14 @@ +const path = require('path') +const { version } = require('../package.json') + +function cache(options) { + if (!options.cache) return + return { + type: 'filesystem', + cacheDirectory: path.posix.join(options.projectFolder, `.${options.environment}`, '.cache'), + name: options.target, + version, + } +} + +module.exports = cache \ No newline at end of file diff --git a/webpack/devtool.js b/webpack/devtool.js new file mode 100644 index 00000000..234071ce --- /dev/null +++ b/webpack/devtool.js @@ -0,0 +1,9 @@ +function devtool(options) { + if (options.environment === 'development') { + return 'eval-cheap-module-source-map' + } else { + return 'hidden-source-map' + } +} + +module.exports = devtool \ No newline at end of file diff --git a/webpack/entry.js b/webpack/entry.js new file mode 100644 index 00000000..cfa6f024 --- /dev/null +++ b/webpack/entry.js @@ -0,0 +1,33 @@ +const path = require('path') + +function client(options) { + if (options.environment === 'production') { + return options.entry + } + return [ + 'webpack-hot-middleware/client?log=false&path=/nullstack/hmr&noInfo=true&quiet=true&timeout=1000&reload=true', + path.posix.join(options.configFolder, 'shared', 'accept.js'), + options.entry + ] +} + +function server(options) { + if (options.environment === 'production') { + return options.entry + } + return [ + 'webpack/hot/poll?100', + path.posix.join(options.configFolder, 'shared', 'accept.js'), + options.entry + ] +} + +function entry(options) { + if (options.target == 'client') { + return client(options) + } else { + return server(options) + } +} + +module.exports = entry \ No newline at end of file diff --git a/webpack/experiments.js b/webpack/experiments.js new file mode 100644 index 00000000..2c9c755f --- /dev/null +++ b/webpack/experiments.js @@ -0,0 +1,12 @@ +function experiments(options) { + return // temporarily disabled + if (options.environment !== 'development') return + return { + lazyCompilation: { + entries: false, + imports: true + } + } +} + +module.exports = experiments \ No newline at end of file diff --git a/webpack/externals.js b/webpack/externals.js new file mode 100644 index 00000000..a7768153 --- /dev/null +++ b/webpack/externals.js @@ -0,0 +1,28 @@ +function client(options) { + if (options.environment === 'production') { + return {} + } + return { + 'webpack-hot-middleware/client': + 'webpack-hot-middleware/client?log=false&path=/nullstack/hmr&noInfo=true&quiet=true&timeout=1000&reload=true', + } +} + +function server(options) { + if (options.environment === 'production') { + return {} + } + return { + 'webpack/hot/poll': 'webpack/hot/poll?100' + } +} + +function externals(options) { + if (options.target == 'client') { + return client(options) + } else { + return server(options) + } +} + +module.exports = externals \ No newline at end of file diff --git a/webpack/infrastructureLogging.js b/webpack/infrastructureLogging.js new file mode 100644 index 00000000..002dc634 --- /dev/null +++ b/webpack/infrastructureLogging.js @@ -0,0 +1,7 @@ +function infrastructureLogging(_) { + return { + level: 'error' + } +} + +module.exports = infrastructureLogging \ No newline at end of file diff --git a/webpack/mode.js b/webpack/mode.js new file mode 100644 index 00000000..ec71f9df --- /dev/null +++ b/webpack/mode.js @@ -0,0 +1,5 @@ +function mode(options) { + return options.environment +} + +module.exports = mode \ No newline at end of file diff --git a/webpack/module.js b/webpack/module.js new file mode 100644 index 00000000..b7e5b018 --- /dev/null +++ b/webpack/module.js @@ -0,0 +1,194 @@ +const path = require('path') +const { readdirSync } = require('fs') + +function icons(options) { + const icons = {} + const publicFiles = readdirSync(path.posix.join(options.projectFolder, 'public')) + const iconFileRegex = /icon-(\d+)x\1\.[a-zA-Z]+/ + for (const file of publicFiles) { + if (iconFileRegex.test(file)) { + const size = file.split('x')[1].split('.')[0] + icons[size] = `/${file}` + } + } + return { ICONS: JSON.stringify(icons) } +} + +function environment(_options) { + const crypto = require('crypto') + const key = crypto.randomBytes(20).toString('hex') + return { KEY: `"${key}"` } +} + +function scss(options) { + if (options.target !== 'client') return + + return { + test: /\.s[ac]ss$/, + use: [ + { + loader: require.resolve('sass-loader'), + options: { sassOptions: { fibers: false } } + } + ], + } +} + +function css(options) { + if (options.target !== 'client') return + + const { loader } = require('mini-css-extract-plugin') + return { + test: /\.s?[ac]ss$/, + use: [ + loader, + { + loader: require.resolve('css-loader'), + options: { url: false } + } + ], + } +} + +function swc(options, other) { + const config = { + test: other.test, + use: { + loader: require.resolve('swc-loader'), + options: { jsc: {}, env: {} } + } + } + + config.use.options.jsc.experimental = { + cacheRoot: path.posix.join(options.projectFolder, options.buildFolder, '.cache', '.swc'), + plugins: [ + [ + require.resolve('swc-plugin-nullstack'), + { + development: options.environment === 'development', + client: options.target === 'client', + template: !!other.template + } + ] + ] + } + + if (options.target === 'server') { + config.use.options.env.targets = { node: process.versions.node } + } else { + config.use.options.env.targets = 'defaults' + } + + config.use.options.jsc.parser = { + syntax: other.syntax, + exportDefaultFrom: true, + } + + if (other.template) { + config.use.options.jsc.parser[other.template] = true + } + + config.use.options.jsc.transform = { + useDefineForClassFields: false, + react: { + pragma: '$runtime.element', + pragmaFrag: '$runtime.fragment', + runtime: 'classic', + throwIfNamespace: true, + }, + } + + if (options.target === 'server' && !other.template) { + config.use.options.jsc.transform.constModules = { + globals: { + "nullstack/environment": environment(options), + "nullstack/project": icons(options), + } + } + } + + return config +} + +function js(options) { + return swc(options, { + test: /\.js$/, + syntax: 'ecmascript', + }) +} + +function ts(options) { + return swc(options, { + test: /\.ts$/, + syntax: 'typescript', + }) +} + +function njs(options) { + return swc(options, { + test: /\.(njs|jsx)$/, + syntax: 'ecmascript', + template: 'jsx', + }) +} + +function nts(options) { + return swc(options, { + test: /\.(nts|tsx)$/, + syntax: 'typescript', + template: 'tsx', + }) +} + +function shutUp(options) { + return { + test: /node_modules[\\/](webpack[\\/]hot|webpack-hot-middleware|mini-css-extract-plugin)/, + loader: path.posix.join(options.configFolder, 'loaders', 'shut-up-loader.js'), + } +} + +function raw() { + return { + issuer: /worker.js/, + resourceQuery: /raw/, + type: 'asset/source', + } +} + +function runtime(options) { + return { + test: /\.(nts|tsx|njs|jsx)$/, + loader: path.posix.join(options.configFolder, 'loaders', 'inject-runtime.js'), + } +} + +function trace(options) { + if (!options.trace) return + process.env.__NULLSTACK_TARGET = options.target + return { + loader: path.posix.join(options.configFolder, 'loaders', 'trace.js'), + } +} + +function rules(options) { + return [ + trace(options), + css(options), + scss(options), + js(options), + ts(options), + njs(options), + nts(options), + shutUp(options), + raw(options), + runtime(options) + ].filter(Boolean) +} + +function iWishModuleWasntAReservedWord(options) { + return { + rules: rules(options) + } +} + +module.exports = iWishModuleWasntAReservedWord \ No newline at end of file diff --git a/webpack/node.js b/webpack/node.js new file mode 100644 index 00000000..013316cf --- /dev/null +++ b/webpack/node.js @@ -0,0 +1,8 @@ +function node(_) { + return { + __dirname: false, + __filename: false, + } +} + +module.exports = node \ No newline at end of file diff --git a/webpack/optimization.js b/webpack/optimization.js new file mode 100644 index 00000000..6889773e --- /dev/null +++ b/webpack/optimization.js @@ -0,0 +1,31 @@ +function js() { + const TerserPlugin = require('terser-webpack-plugin') + return new TerserPlugin({ + minify: TerserPlugin.swcMinify, + terserOptions: { + mangle: false, + compress: { + unused: false, + }, + keepFnames: true, + sourceMap: true, + } + }) +} + +function css(options) { + if (options.target !== 'client') return false + const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); + return new CssMinimizerPlugin({ + minify: CssMinimizerPlugin.lightningCssMinify, + }) +} + +function optimization(options) { + return { + minimize: options.environment === 'production', + minimizer: [js(options), css(options)].filter(Boolean), + } +} + +module.exports = optimization \ No newline at end of file diff --git a/webpack/output.js b/webpack/output.js new file mode 100644 index 00000000..b5ee5d24 --- /dev/null +++ b/webpack/output.js @@ -0,0 +1,22 @@ +const path = require('path') + +function output(options) { + return { + publicPath: `/`, + path: path.posix.join(options.projectFolder, options.buildFolder), + filename: `${options.target}.js`, + chunkFilename: `[chunkhash].${options.target}.js`, + hotUpdateChunkFilename: `nullstack-${options.target}-update-[id]-[fullhash].js`, + hotUpdateMainFilename: `nullstack-${options.target}-update-[runtime]-[fullhash].json`, + pathinfo: false, + libraryTarget: 'umd', + clean: false, + // { + // keep(asset) { + // return asset.startsWith('.') || !asset.includes(options.target) + // }, + // } + } +} + +module.exports = output \ No newline at end of file diff --git a/webpack/plugins.js b/webpack/plugins.js new file mode 100644 index 00000000..f3f7fbe8 --- /dev/null +++ b/webpack/plugins.js @@ -0,0 +1,53 @@ +function css(options) { + if (options.target !== 'client') return false + + const MiniCssExtractPlugin = require('mini-css-extract-plugin') + return new MiniCssExtractPlugin({ + filename: `${options.target}.css`, + chunkFilename: `[chunkhash].${options.target}.css`, + }) +} + +function hmr(options) { + if (options.environment !== 'development') return false + + const { HotModuleReplacementPlugin } = require('webpack') + return new HotModuleReplacementPlugin() +} + +function copy(options) { + if (!options.disk) return false + + const CopyPlugin = require('copy-webpack-plugin') + return new CopyPlugin({ + patterns: [ + { from: 'public', to: `../${options.buildFolder}` } + ], + }) +} + +function nodemon(options) { + if (options.environment !== 'development') return false + if (options.target !== 'server') return false + + const NodemonPlugin = require('nodemon-webpack-plugin') + const dotenv = options.name ? `.env.${options.name}` : '.env' + return new NodemonPlugin({ + ext: '*', + watch: [dotenv, './server.js'], + script: './.development/server.js', + nodeArgs: ['--enable-source-maps'], + quiet: true, + }) +} + +function plugins(options) { + return [ + css(options), + hmr(options), + copy(options), + nodemon(options), + ].filter(Boolean) +} + +module.exports = plugins \ No newline at end of file diff --git a/webpack/resolve.js b/webpack/resolve.js new file mode 100644 index 00000000..a65e6f10 --- /dev/null +++ b/webpack/resolve.js @@ -0,0 +1,12 @@ +const path = require('path') + +function resolve(options) { + return { + extensions: ['.njs', '.js', '.nts', '.ts', '.tsx', '.jsx'], + alias: { + nullstack: path.join(options.configFolder, options.target), + } + } +} + +module.exports = resolve \ No newline at end of file diff --git a/webpack/stats.js b/webpack/stats.js new file mode 100644 index 00000000..1189d8a6 --- /dev/null +++ b/webpack/stats.js @@ -0,0 +1,5 @@ +function stats(_) { + return 'none' +} + +module.exports = stats \ No newline at end of file diff --git a/webpack/target.js b/webpack/target.js new file mode 100644 index 00000000..cff58f9e --- /dev/null +++ b/webpack/target.js @@ -0,0 +1,5 @@ +function target(options) { + return options.target == 'client' ? 'web' : 'node' +} + +module.exports = target \ No newline at end of file diff --git a/workers/dynamicInstall.js b/workers/dynamicInstall.js index f9f088eb..7dcead3f 100644 --- a/workers/dynamicInstall.js +++ b/workers/dynamicInstall.js @@ -5,7 +5,7 @@ function install(event) { '/manifest.webmanifest', `/client.js?fingerprint=${self.context.environment.key}`, `/client.css?fingerprint=${self.context.environment.key}`, - '{{BUNDLE}}', + globalThis.__NULLSTACK_BUNDLE, ] event.waitUntil( (async function () { diff --git a/workers/staticInstall.js b/workers/staticInstall.js index 7c128077..f18c8774 100644 --- a/workers/staticInstall.js +++ b/workers/staticInstall.js @@ -5,7 +5,7 @@ function install(event) { '/manifest.webmanifest', `/client.js?fingerprint=${self.context.environment.key}`, `/client.css?fingerprint=${self.context.environment.key}`, - '{{BUNDLE}}', + globalThis.__NULLSTACK_BUNDLE, `/nullstack/${self.context.environment.key}/offline/index.html`, ].flat() event.waitUntil(