meta // Oct 27, 2021
Introducing Joystick: The Full-Stack JavaScript Framework
Today, I'm beyond thrilled to introduce you to Joystick.
Joystick is a full-stack JavaScript framework. Right now, that consists of a front-end UI library @joystick.js/ui
, a back-end library @joystick.js/node
, and a command-line interface @joystick.js/cli
. The ui
and node
packages can be used independently, but are designed and intended to be used together.
Joystick is free and MIT-licensed. No, you won't have to sell me your soul to access it or worry about financing my future ambitions in eugenics or authoritarianism. Consider this my thank you gift to all of the developers who came before me, all of the developers just getting started, and everyone in between.
If you want to dig in, head over to the repo on Github and check out the docs.
The front-end
@joystick.js/ui
brings a component-based approach to writing HTML, CSS, and JavaScript in a more organized, friendly way. It does not introduce any new languages or syntax. Components built with @joystick.js/ui
look like this:
Example Joystick Component
import ui from "@joystick.js/ui";
import Quote from "../../components/quote";
const Index = ui.component({
methods: {
handleLogHello: () => {
console.log("Hello!");
},
},
events: {
"click .say-hello": (component) => {
component.methods.handleLogHello();
},
},
css: `
div p {
font-size: 18px;
background: #eee;
padding: 20px;
}
`,
render: ({ component, i18n }) => {
return `
<div>
<p>${i18n("quote")}</p>
${component(Quote, {
quote: "Light up the darkness.",
attribution: "Bob Marley",
})}
</div>
`;
},
});
export default Index;
The render()
function for components piggybacks on JavaScript's template literal convention. You return a string of HTML from it, using JavaScript interpolation to embed one of a few render functions: component()
, each()
, i18n()
, and when()
.
component()
allows you to render another component (enabling composition of components), each()
helps you loop over and render data in arrays, i18n()
helps you render internationalization strings, and when()
helps you conditionally render HTML based on some variable.
In addition to the render()
method, components have a few other options:
css
which can be set to a string of CSS (or a function which takes in the component instance and returns a string of CSS) that's dynamically scoped to your component and injected into the page.events
which are JavaScript DOM events dynamically scoped to your component.methods
which are miscellaneous, domain-specific functions for your component (and can be called by accessing the component instance passed to all other options).lifecycle
which is a fixed set of methods—onBeforeMount
,onMount
, andonBeforeUnmount
—that fire in relation to the lifecycle of the component.state
an object (or function returning an object) that represents the default state of the component and can be modified via the.setState()
method on the component instance.
@joystick.js/ui
also exports a get()
and set()
function for performing HTTP GET requests and HTTP POST requests to your API (more on this below), using input validation and Joystick's SelectiveFetch to customize the output returned from the API. They look like this in use:
get() example
get('posts', {
input: {
category: 'tutorials',
},
output: ['title', 'author.name', 'publishedAt']
}).then((data) => {
// Use data returned here...
});
set() example
set('createPost', {
input: {
title: 'A new blog post',
body: 'The body of the post.',
},
output: ['_id']
}).then((data) => {
// Use data returned here...
});
For user accounts, an accounts
object is also exported from @joystick.js/ui
with five methods on it:
signup()
which is used to create new users.login()
which is used to login existing users.logout()
which is used to logout an existing user.recoverPassword()
which is used to initiate a password reset.resetPassword()
which is used to reset a password.
That's the front-end. The goal is to keep this API fixed, only adding things (if absolutely necessary) as time passes. Components you write in Joystick today will work with Joystick in 10 years. My foot is down on this.
The back-end
On the back-end, @joystick.js/node
is a thin layer over Express.js along with some other features. The centerpiece being the .app()
method accessible via the object exported from the package (you will most commonly see this in boilerplate and example code as node.app()
).
The node.app()
function is responsible for:
- Registering your
routes
which are normal Express.js routes with a thin layer of syntactic sugar over them to keep things organized. Routes are assigned a specialres.render()
function to help you render Joystick components. - Registering your
api
built using@joystick.js/node
's getter and setter functions. - Registering any custom
middleware
to run before each route. - Registering any listeners for standard Node.js
events
likeuncaughtException
orunhandledRejection
.
More functionality will be added to node.app()
as Joystick approaches a 1.0.0
.
For routes, res.render()
is the only magic trick in the framework (the rest is just plumbing with pants pulled up).
This function takes in the path to a Joystick component in your ui/pages
directory and does a few things:
- Server-side renders the page as static HTML and CSS.
- Automatically embeds the script representing the compiled JavaScript version of the component to be loaded in the browser.
- Utilizes the automatically injected mount script in
@joystick.js/cli
to automatically "hydrate" the component on the client (swap the SSR rendered HTML with the dynamic JavaScript version).
For you, this means super-fast mostly hands-free rendering of pages/components. The only thing sent to the browser is what's needed to render that page.
For the API portion of node.app()
, Joystick introduces the concept of getters and setters. They look like this:
Defining a schema in Joystick
{
getters: {
posts: {
input: {
category: {
type: "string",
},
},
get: (input, context) => {
const query = {};
if (input.category) {
query.category = input.category;
}
return context.mongodb.collection('posts').findOne(query).toArray();
},
},
},
setters: {
createPost: {
input: {
title: {
type: "string",
required: true,
},
body: {
type: "string",
},
},
set: async (input, context) => {
const postId = joystick.id();
await context.mongodb.collection('posts').insertOne(input);
return {
_id: postId,
};
},
},
},
}
All of your getters and setters are organized into one object (referred to as your API's schema), with each getter or setter nested under the object of its respective type.
Getters and setters are nearly identical save for their intent. They allow you to define validation for any input
passed to them and a function get()
or set()
to respond to the request. Input validation uses a library that's built-in to Joystick.
You'll also notice that the get()
and set()
functions receive a context
argument alongside the input
. This gives you access to the HTTP req
and res
objects from Express.js (getters and setters are implemented using Express.js routes just like your other routes) as well as any databases you've loaded via the CLI (more on this below) and, if applicable, the current user.
The final piece of string tying all of this together is @joystick.js/cli
, a command-line interface (CLI) that helps you to create new Joystick projects and start a development server for existing ones. It also helps you to start databases in development and communicate with @joystick.js/node
to load the appropriate driver so your database is automatically available in your app.
Project creation is simple. After installing @joystick.js/cli
via NPM (npm i -g @joystick.js/cli
), just run joystick create <project>
where <project>
is the name of the directory your project will be created (e.g., joystick create banana-stand
).
To start your development server, cd
into your Joystick project's folder and run joystick start
to start your app at http://localhost:2600
(if you're not an Atari fan, you can run your app on any available port with joystick start --port <port>
).
The databases @joystick.js/cli
starts up for your app in development are determined by the config.databases
array in your app's settings.development.json
file (automatically created for you via joystick create
).
Once started, the native Node.js driver for each database started will be accessible in one of three ways (each equal and purely existing for convenience):
- On your
routes
defined vianode.app()
as part of thereq.context
object. Each database is available by its name here (e.g.,req.context.mongodb
orreq.context.postgresql
). - On the
context
object passed to theget
andset
functions on your getters and setters in the API. - Globally via the Node.js
process
object with each database being defined by name (e.g.,process.mongodb
orprocess.postgresql
).
Joystick makes the conncetion to your database (which can be configured as part of the config.databases
array in your settings) but does not modify the native behavior of the database or its driver. Once connected, you're at the mercy of the driver and its documentation (and my benevolence).
As part of this setup, Joystick can also selectively map your user accounts to the databsae of your choice. Just set users
to true
in your config.databases
array and Joystick will handle the plumbing.
That's the framework. There are more, smaller features explained in the documentation.
Joystick is proprietary
I've had enough bullets ricochet off my helmet in the trenches to know that an Apple-style "walled garden" approach to Joystick's development is best.
As much as possible—and makes sense—Joystick will be focused on giving you what you need out of the box with usage of third-party libraries being reserved for the truly esoteric, uncommon stuff.
The goal of this is two-fold: to reduce the amount of messy, fragile code in your app and to offer a stable, long-term focused framework that doesn't leave you out in the cold on functionality that should be built-in.
This concept will be foreign to and rejected by many people, but in time I hope to slam my scepter down at the base of my throne and hear the electorate cheer in approval.
Joystick is opinionated
Joystick is highly opinionated. The battlefield trauma I've experienced as a mentor to developers has led me to develop a "get off my lawn" attitude towards how things should be done. What I've learned over the years is that, without a clear, definitive process, Murphy's law kicks in and any code that can be written, will be written.
Folder and file structure are specific and forced. The feature set will be 100% driven by my own mental illness and input from developers actively using the framework (not will-o-the-wisp computer science graduates who've failed to wipe the ketchup off their shirt having a bad morning). For now, pull requests will be turned away unless they're carrying a supreme pizza and delivered by an attractive brunette woman.
Joystick's API will not be subject to random change. At worst, I'll add features, but no "Hey, guess what?! You have to refactor your entire codebase to keep using the framework! Awesome!"
The design of Joystick was and is highly intentional and highly labored over. I sincerely don't care if you think you're better than or know more than me. I will handle people like this with far less tact than Steve Jobs.
Joystick will be in beta for now
Why a beta? Well, up until now, barely anybody knew I was working on this (and I'm fairly certain the ones that did thought I was full of 💩), which was intentional.
As a result, I only know about the problems I know about. While things do work in general, I'm careful to give them my final stamp of approval as I'm certain there are plenty of bugs lurking in the bushes. Today's release is less about perfection and more about coming out of obscurity. I want to spend the next few months writing tests, clarifying APIs and documentation, and thinking about any loose ends in terms of feature set before I say "this is a 1.0."
Expect a dash of chaos while I stitch the bird up before putting it in the oven. If you're looking to build a mission critical app: wait until the 1.0 drops (unless you have a gambling problem, then go for it).
That said, you're encouraged to play. Find stuff that doesn't work. Find stuff that doesn't make sense. But for the love of God, don't leave me passive aggressive comments on Github: I won't be pleasant and will go out of my way to publicly embarass you. If you're kind and honest: I will climb over mountains for you.
And here...we...go
Admittedly, this is picking a fight. But it's a fight I feel is necessary and meaningful. To be blunt: web development with JavaScript has gone off the rails. It's left its family, bought a bunch of Tommy Bahama shirts, moved to South America and impregnated a young Peruvian girl.
Having a midlife crisis is fine—acceptable, even, if done tastefully—but it's not conducive to building great software. Developers old and new need stable software that's easy to understand and keeps them productive—not a never-ending rug pull with passive shouts of condescension.
This will absolutely piss some people off and I welcome their discontent. If JavaScript is going to move in a positive, sane direction long-term (and I'm going to avoid jail time), it's necessary.
Ante up.