diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..6581fe76 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Nullstack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0fb09b26..5d385b69 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,4 @@ Get to know the [Nullstack Contributors](https://nullstack.app/contributors) ## License -Nullstack is released under the [MIT License](https://opensource.org/licenses/MIT). +Nullstack is released under the [MIT License](LICENSE). diff --git a/client/instanceProxyHandler.js b/client/instanceProxyHandler.js index 6c68ac68..fb8d9d2d 100644 --- a/client/instanceProxyHandler.js +++ b/client/instanceProxyHandler.js @@ -1,5 +1,5 @@ import client from './client' -import { generateContext } from './context' +import context, { generateContext } from './context' import { generateObjectProxy } from './objectProxyHandler' export const instanceProxies = new WeakMap() @@ -15,8 +15,26 @@ const instanceProxyHandler = { } const { [name]: named } = { [name]: (args) => { - const context = generateContext({ ...target._attributes, ...args }) - return target[name].call(proxy, context) + const scopedContext = generateContext({ ...target._attributes, ...args }) + let result + try { + result = target[name].call(proxy, scopedContext) + } catch (error) { + if (context.catch) { + context.catch(error) + } else { + throw error + } + return null + } + if (result instanceof Promise) { + return new Promise((resolve, reject) => { + result.then(resolve).catch((error) => { + context.catch ? context.catch(error) : reject(error) + }) + }) + } + return result }, } return named diff --git a/loaders/add-source-to-node.js b/loaders/add-source-to-node.js index 435306c6..c92bd5a0 100644 --- a/loaders/add-source-to-node.js +++ b/loaders/add-source-to-node.js @@ -13,7 +13,9 @@ module.exports = function (source) { 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) => a.name.name === 'source') + 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) diff --git a/package.json b/package.json index 9c45b534..bf2b945e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nullstack", - "version": "0.17.4", + "version": "0.17.5", "description": "Full Stack Javascript Components for one-dev armies", "main": "nullstack.js", "author": "Mortaro", diff --git a/plugins/bindable.js b/plugins/bindable.js index efcf938e..b3424084 100644 --- a/plugins/bindable.js +++ b/plugins/bindable.js @@ -9,7 +9,7 @@ function transform({ node, environment }) { const object = node.attributes.bind.object ?? {} const property = node.attributes.bind.property if (node.type === 'textarea') { - node.children = [object[property]] + node.children = [object[property] ?? ''] } else if (node.type === 'input' && node.attributes.type === 'checkbox') { node.attributes.checked = object[property] } else { diff --git a/server/printError.js b/server/printError.js index a2daa484..4fbd51d4 100644 --- a/server/printError.js +++ b/server/printError.js @@ -1,4 +1,9 @@ +import context from './context' + export default function (error) { + if (context.catch) { + context.catch(error) + } const lines = error.stack.split(`\n`) let initiator = lines.find((line) => line.indexOf('Proxy') > -1) if (initiator) { diff --git a/server/reqres.js b/server/reqres.js index c743ad43..13cdb10c 100644 --- a/server/reqres.js +++ b/server/reqres.js @@ -1,2 +1,20 @@ -const reqres = {} +class ReqRes { + + request = null + response = null + + set(request, response) { + this.request = request + this.response = response + } + + clear() { + this.request = null + this.response = null + } + +} + +const reqres = new ReqRes() + export default reqres diff --git a/server/server.js b/server/server.js index dac39bb9..3f612891 100644 --- a/server/server.js +++ b/server/server.js @@ -40,6 +40,7 @@ for (const method of ['get', 'post', 'put', 'patch', 'delete', 'all']) { 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]) @@ -48,14 +49,17 @@ for (const method of ['get', 'post', 'put', 'patch', 'delete', 'all']) { params[key] = extractParamValue(request.query[key]) } if (request.method !== 'GET') { - Object.assign(params, deserialize(request.body)) + 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({}) } }) @@ -224,6 +228,7 @@ server.start = function () { server.all(`/${prefix}/: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 { hash, methodName } = request.params const [invokerHash, boundHash] = hash.split('-') @@ -241,12 +246,15 @@ server.start = function () { 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({}) } }) @@ -255,21 +263,23 @@ server.start = function () { if (request.originalUrl.split('?')[0].indexOf('.') > -1) { return next() } - reqres.request = request - reqres.response = response + reqres.set(request, response) const scope = await prerender(request, response) if (!response.headersSent) { const status = scope.context.page.status const html = template(scope) - reqres.request = null - reqres.response = null + reqres.clear() response.status(status).send(html) } else { - reqres.request = null - reqres.response = null + reqres.clear() } }) + server.use((error, _request, response, _next) => { + printError(error) + response.status(500).json({}) + }) + if (!server.less) { if (!server.port) { console.info('\x1b[31mServer port is not defined!\x1b[0m') diff --git a/tests/client.js b/tests/client.js index 24b58b0e..9a0b4da5 100644 --- a/tests/client.js +++ b/tests/client.js @@ -1,6 +1,7 @@ import Nullstack from 'nullstack' import Application from './src/Application' +import CatchError from './src/CatchError.njs' import vueable from './src/plugins/vueable' Nullstack.use(vueable) @@ -12,4 +13,11 @@ context.start = function () { setTimeout(() => (context.startTimedValue = true), 1000) } +context.catch = async function (error) { + CatchError.logError({ message: error.message }) + if (context.environment.development) { + console.error(error) + } +} + export default context diff --git a/tests/server.js b/tests/server.js index 62df2bbb..e5428264 100644 --- a/tests/server.js +++ b/tests/server.js @@ -1,14 +1,17 @@ import Nullstack from 'nullstack' import cors from 'cors' +import express from 'express' import Application from './src/Application' +import CatchError from './src/CatchError' import ContextProject from './src/ContextProject' import ContextSecrets from './src/ContextSecrets' import ContextSettings from './src/ContextSettings' import ContextWorker from './src/ContextWorker' import ExposedServerFunctions from './src/ExposedServerFunctions' import vueable from './src/plugins/vueable' +import ReqRes from './src/ReqRes' Nullstack.use(vueable) @@ -22,6 +25,7 @@ context.server.use( optionsSuccessStatus: 200, }), ) +context.server.use(express.json()) context.worker.staleWhileRevalidate = [/[0-9]/] context.worker.cacheFirst = [/[0-9]/] @@ -40,6 +44,9 @@ context.server.put('/data/put/:param', ExposedServerFunctions.getData) context.server.patch('/data/patch/:param', ExposedServerFunctions.getData) context.server.delete('/data/delete/:param', ExposedServerFunctions.getData) +context.server.get('/exposed-server-function-url.json', ReqRes.exposedServerFunction) +context.server.get('/nested-exposed-server-function-url.json', ReqRes.nestedExposedServerFunction) + context.server.get('/custom-api-before-start', (request, response) => { response.json({ startValue: context.startValue }) }) @@ -57,6 +64,10 @@ for (const method of methods) { }) } +context.server.get('/vaidamerdanaapi.json', (_request, response) => { + response.vaidamerdanaapi() +}) + context.startIncrementalValue = 0 context.start = async function () { @@ -68,4 +79,11 @@ context.start = async function () { context.startIncrementalValue++ } +context.catch = async function (error) { + CatchError.logError({ message: error.message }) + if (context.environment.development) { + console.error(error) + } +} + export default context diff --git a/tests/src/Application.njs b/tests/src/Application.njs index 25ff8241..997742df 100644 --- a/tests/src/Application.njs +++ b/tests/src/Application.njs @@ -4,6 +4,7 @@ import AnchorModifiers from './AnchorModifiers' import './Application.css' import ArrayAttributes from './ArrayAttributes' import BodyFragment from './BodyFragment' +import CatchError from './CatchError' import ChildComponent from './ChildComponent' import ComponentTernary from './ComponentTernary' import Context from './Context' @@ -40,6 +41,7 @@ import PublicServerFunctions from './PublicServerFunctions.njs' import PureComponents from './PureComponents' import Refs from './Refs' import RenderableComponent from './RenderableComponent' +import ReqRes from './ReqRes' import RoutesAndParams from './RoutesAndParams' import RouteScroll from './RouteScroll' import ServerFunctions from './ServerFunctions' @@ -137,6 +139,8 @@ class Application extends Nullstack { + + ) diff --git a/tests/src/CatchError.njs b/tests/src/CatchError.njs new file mode 100644 index 00000000..490e7b40 --- /dev/null +++ b/tests/src/CatchError.njs @@ -0,0 +1,47 @@ +import Nullstack from 'nullstack' + +import { appendFileSync } from 'fs' + +const error = new Error('Panic in the system, someone missconfigured me!') + +class CatchError extends Nullstack { + + static async logError({ message, environment }) { + const folder = environment.production ? '.production' : '.development' + appendFileSync(`${folder}/exceptions.txt`, `${message}\n`) + } + + static async getServerFunction() { + throw error + } + + async oneErrorPls() { + throw error + } + + async hydrate({ params }) { + setTimeout(() => (this.hydrated = true), 0) + if (params.error === 'hydrate') { + await this.vaidamerdanoclient() + } + } + + async initiate({ params, page }) { + if (params.error === 'initiate' && page.status === 200) { + this.vaidamerdanoserver() + } else if (params.error === 'initiateServerFunction') { + await this.getServerFunction() + } + } + + render() { + return ( +
+ +
+ ) + } + +} + +export default CatchError diff --git a/tests/src/CatchError.test.js b/tests/src/CatchError.test.js new file mode 100644 index 00000000..838aff2c --- /dev/null +++ b/tests/src/CatchError.test.js @@ -0,0 +1,34 @@ +const { readFileSync } = require('fs') + +describe('CatchError initiate', () => { + test('errors caused by instances can be caught on the server', async () => { + await page.goto('http://localhost:6969/catch-error?error=initiate') + const logs = readFileSync('.production/exceptions.txt', 'utf-8') + expect(logs.includes('this.vaidamerdanoserver is not a function')).toBeTruthy() + }) +}) + +describe('CatchError initiateServerFunction', () => { + test('errors caused by server functions can be caught on the server', async () => { + await page.goto('http://localhost:6969/catch-error?error=initiateServerFunction') + const logs = readFileSync('.production/exceptions.txt', 'utf-8') + expect(logs.includes('Panic in the system, someone missconfigured me!')).toBeTruthy() + }) +}) + +describe('CatchError hydrate', () => { + test('errors caused by instances can be caught on the client', async () => { + await page.goto('http://localhost:6969/catch-error?error=hydrate') + await page.waitForSelector('[data-hydrated]') + const logs = readFileSync('.production/exceptions.txt', 'utf-8') + expect(logs.includes('this.vaidamerdanoclient is not a function')).toBeTruthy() + }) +}) + +describe('CatchError api', () => { + test('errors caused by api routes can be caught on the server', async () => { + await page.goto('http://localhost:6969/vaidamerdanaapi.json') + const logs = readFileSync('.production/exceptions.txt', 'utf-8') + expect(logs.includes('response.vaidamerdanaapi is not a function')).toBeTruthy() + }) +}) diff --git a/tests/src/ExposedServerFunctions.jsx b/tests/src/ExposedServerFunctions.jsx index 40951a87..16950726 100644 --- a/tests/src/ExposedServerFunctions.jsx +++ b/tests/src/ExposedServerFunctions.jsx @@ -1,14 +1,19 @@ import Nullstack from 'nullstack' -async function api(method) { - const body = { +async function api(method, contentType = 'text/plain') { + const payload = { number: 69, date: new Date(), string: 'nullstack', } const response = await fetch(`/data/${method}/param?query=query&truthy=true&falsy=false`, { method: method.toUpperCase(), - body: method === 'get' ? undefined : JSON.stringify(body), + ...(method !== 'get' && { + headers: { + 'Content-Type': contentType, + }, + body: JSON.stringify(payload), + }), }) const data = await response.json() return data.status @@ -40,20 +45,28 @@ class ExposedServerFunctions extends Nullstack { this.chainableRegularFunction = await chainable('regular') this.all = await api('get') this.get = await api('get') - this.post = await api('post') - this.put = await api('put') - this.patch = await api('patch') - this.delete = await api('delete') + this.postTextPayload = await api('post') + this.postJsonPayload = await api('post', 'application/json') + this.putTextPayload = await api('put') + this.putJsonPayload = await api('put', 'application/json') + this.patchTextPayload = await api('patch') + this.patchJsonPayload = await api('patch', 'application/json') + this.deleteTextPayload = await api('delete') + this.deleteJsonPayload = await api('delete', 'application/json') } render() { return (
{ expect(element).toBeTruthy() }) - test('server functions can be exposed to POST and serialize params and query and body', async () => { + test('server functions can be exposed to POST and serialize params and query and body as text', async () => { await page.waitForSelector('[data-post]') const element = await page.$('[data-post]') expect(element).toBeTruthy() }) - test('server functions can be exposed to PUT and serialize params and query and body', async () => { + test('server functions can be exposed to POST and serialize params and query and body as json', async () => { + await page.waitForSelector('[data-post-json]') + const element = await page.$('[data-post-json]') + expect(element).toBeTruthy() + }) + + test('server functions can be exposed to PUT and serialize params and query and body as text', async () => { await page.waitForSelector('[data-put]') const element = await page.$('[data-put]') expect(element).toBeTruthy() }) - test('server functions can be exposed to PATCH and serialize params and query and body', async () => { + test('server functions can be exposed to PUT and serialize params and query and body as json', async () => { + await page.waitForSelector('[data-put-json]') + const element = await page.$('[data-put-json]') + expect(element).toBeTruthy() + }) + + test('server functions can be exposed to PATCH and serialize params and query and body as text', async () => { await page.waitForSelector('[data-patch]') const element = await page.$('[data-patch]') expect(element).toBeTruthy() }) - test('server functions can be exposed to DELETE and serialize params and query and body', async () => { + test('server functions can be exposed to PATCH and serialize params and query and body as json', async () => { + await page.waitForSelector('[data-patch-json]') + const element = await page.$('[data-patch-json]') + expect(element).toBeTruthy() + }) + + test('server functions can be exposed to DELETE and serialize params and query and body as text', async () => { await page.waitForSelector('[data-delete]') const element = await page.$('[data-delete]') expect(element).toBeTruthy() }) + test('server functions can be exposed to DELETE and serialize params and query and body as json', async () => { + await page.waitForSelector('[data-delete-json]') + const element = await page.$('[data-delete-json]') + expect(element).toBeTruthy() + }) + test('server functions can be exposed to ALL and serialize params and query and body', async () => { await page.waitForSelector('[data-all]') const element = await page.$('[data-all]') diff --git a/tests/src/RenderableComponent.njs b/tests/src/RenderableComponent.njs index d469e803..59a00f25 100644 --- a/tests/src/RenderableComponent.njs +++ b/tests/src/RenderableComponent.njs @@ -28,6 +28,7 @@ class RenderableComponent extends Nullstack { render({ params }) { const list = params.shortList ? [1, 2, 3] : [1, 2, 3, 4, 5, 6] const html = ' Nullstack ' + const props = { disabled: true, 'aria-label': 'props' } return (
@@ -56,16 +57,17 @@ class RenderableComponent extends Nullstack { {!!params.condition &&
conditionally rendered div
} - {' '} - long list{' '} + long list - {' '} - long list{' '} + long list
+
) } diff --git a/tests/src/RenderableComponent.test.js b/tests/src/RenderableComponent.test.js index 6eaf9920..c2a63b50 100644 --- a/tests/src/RenderableComponent.test.js +++ b/tests/src/RenderableComponent.test.js @@ -115,6 +115,11 @@ describe('RenderableComponent ?condition=true', () => { const element = await page.$('div.element') expect(element).toBeTruthy() }) + + test('elements can spread props and have named attributes', async () => { + const element = await page.$('[disabled][aria-label="props"]') + expect(element).toBeTruthy() + }) }) describe('RenderableComponent ?shortList=true', () => { diff --git a/tests/src/ReqRes.njs b/tests/src/ReqRes.njs new file mode 100644 index 00000000..8907b0c1 --- /dev/null +++ b/tests/src/ReqRes.njs @@ -0,0 +1,80 @@ +import Nullstack from 'nullstack' + +class ReqRes extends Nullstack { + + static async innerNestedServerFunctionFromClient(context) { + return context.request.originalUrl + } + + static async nestedServerFunctionFromClient() { + return this.innerNestedServerFunctionFromClient() + } + + static async serverFunctionFromClient(context) { + return context.request.originalUrl + } + + static async innerNestedServerFunction(context) { + return context.request.originalUrl + } + + static async serverFunction(context) { + return context.request.originalUrl + } + + static async nestedServerFunction() { + return this.innerNestedServerFunction() + } + + static async innerNestedExposedServerFunction(context) { + return context.request.originalUrl + } + + static async nestedExposedServerFunction() { + return this.innerNestedExposedServerFunction() + } + + static async exposedServerFunction(context) { + return context.request.originalUrl + } + + async initiate() { + this.serverFunctionUrl = await ReqRes.serverFunction() + this.nestedServerFunctionUrl = await ReqRes.nestedServerFunction() + } + + async fetchServerFunction({ slug }) { + const response = await fetch(`/${slug}.json`) + return response.json() + } + + async hydrate() { + this.serverFunctionFromClientUrl = await ReqRes.serverFunctionFromClient() + this.nestedServerFunctionFromClientUrl = await ReqRes.nestedServerFunctionFromClient() + this.exposedServerFunctionUrl = await this.fetchServerFunction({ slug: 'exposed-server-function-url' }) + this.nestedExposedServerFunctionUrl = await this.fetchServerFunction({ slug: 'nested-exposed-server-function-url' }) + } + + render() { + return ( +
+ ) + } + +} + +export default ReqRes diff --git a/tests/src/ReqRes.test.js b/tests/src/ReqRes.test.js new file mode 100644 index 00000000..6c52e245 --- /dev/null +++ b/tests/src/ReqRes.test.js @@ -0,0 +1,42 @@ +beforeAll(async () => { + await page.goto('http://localhost:6969/reqres') + await page.waitForSelector('[data-hydrated]') +}) + +describe('ReqRes', () => { + test('server functions keep the request', async () => { + await page.waitForSelector('[data-server-function]') + const element = await page.$('[data-server-function]') + expect(element).toBeTruthy() + }) + + test('nested server functions keep the request', async () => { + await page.waitForSelector('[data-nested-server-function]') + const element = await page.$('[data-nested-server-function]') + expect(element).toBeTruthy() + }) + + test('server functions keep the request when invoked by the client', async () => { + await page.waitForSelector('[data-server-function-from-client]') + const element = await page.$('[data-server-function-from-client]') + expect(element).toBeTruthy() + }) + + test('nested server functions keep the request when invoked by the client', async () => { + await page.waitForSelector('[data-nested-server-function-from-client]') + const element = await page.$('[data-nested-server-function-from-client]') + expect(element).toBeTruthy() + }) + + test('exposed server functions keep the request', async () => { + await page.waitForSelector('[data-exposed-server-function]') + const element = await page.$('[data-exposed-server-function]') + expect(element).toBeTruthy() + }) + + test('nested exposed server functions keep the request', async () => { + await page.waitForSelector('[data-exposed-nested-server-function]') + const element = await page.$('[data-exposed-nested-server-function]') + expect(element).toBeTruthy() + }) +}) diff --git a/tests/src/TwoWayBindings.njs b/tests/src/TwoWayBindings.njs index cc73b6b7..64a77596 100644 --- a/tests/src/TwoWayBindings.njs +++ b/tests/src/TwoWayBindings.njs @@ -97,6 +97,8 @@ class TwoWayBindings extends Nullstack { +