In the previous episode, we setup a mostly empty project, and now we want to start building out the project iteratively bit by bit.
The core feature of a forum is the post. If you cannot read posts and reply to them, you don't have a forum.
Can you have a forum without categories? You can.
Can you have a forum without user auth? Yes you can. Everyone can be anonymous. If they can read and participate in discussions, it's a forum.
Can you have a forum without search? Yes you can. People can just share links, or external sites can index the content and expose search feature.
So normally I'd say: ignore everything and start with the discussion. I'm even trying to work out a UI design:
However, I was to start by introducing the basic features of our mini framework:
RPC Wiring
UI State Management
Data persistence & Indexing
So we'll work on something simple that allows us to showcase how our framework works.
To this end, I decided to start with a basic, bare bones, user registration system.
Features of a basic auth system
Form to create an account; nothing but username, email, and password
Form to login to account; nothing but username and password
Session management using tokens
Distinguishing "Admin" accounts
Page to list all accounts
Persisting account and session data
[DEBUG] Button to login as any account (if you are admin)
Things we will not do now:
Email confirmation
Pagination for the user list page
User Profile & Bio
Stage 1: Volatile anonymous "accounts"
We start with the most basic thing: an input box an a submit button. You can create as many accounts as you want, and the server keeps track of them.
We start by creating a procedure on the server side that is exposed to the client. The input is the new username, the output is a list of all current usernames.
// global (but volatile) list of usernames
var usernames []string
type AddUserRequest struct {
Username string
}
type UserListResponse struct {
AllUsernames []string
}
func AddUser(ctx *vbeam.Context, req AddUserRequest) (resp UserListResponse, err error) {
usernames = append(usernames, req.Username)
resp.AllUsernames = usernames
return
}
This procedure does not do much other than appending the given name to a list and returning the list.
Input and Output params must be structs
First input param is
*vbeam.Context
Second output param is error
The content is regular code that does not care about HTTP or JSON.
Now, to expose this code to the client, we call vbeam.RegisterProc(app, AddUser)
inside the MakeApplication
function:
func MakeApplication() *vbeam.Application {
vbeam.RunBackServer(cfg.Backport)
db := vbolt.Open(cfg.DBPath)
var app = vbeam.NewApplication("HandCraftedForum", db)
vbeam.RegisterProc(app, AddUser) // <<==== Added!
return app
}
Great, now, how can we test that this works? Do we need to build the UI first?
Actually no. There's a much simpler way.
Inside main.tsx
add a line to import the generated server
module, and expose it on window
import * as vlens from "vlens";
import * as server from "@app/server" // <==== added
async function main() {
vlens.initRoutes([
vlens.routeHandler("/", () => import("@app/home")),
]);
}
main();
(window as any).server = server // <==== added
Now, we can call it from the browser console!
Just like the Go function returns a response and an error, so does the generated binding function. It returns a response and an error, except the error is a string type. When there's an error, the response is set to null and the error is the returned error message.
Since it goes over the network, it needs to be awaited
typescript
let [resp, err] = await server.AddUser({Username: "admin"})
This is the basic pattern for communication between the client and the server.
Now let's create the client side code. This will be a special page so let's create a new route, for instance, `/users`
The idea is to show the current list of users, let you add new ones, and then "select" a user to login as.
We start by adding the route, and note that we have to add it before the '/' route, because we match like Go's http routes, by prefix, and the first route that matches as a prefix for the current location will be picked.
The initial content of users.tsx
is to just list the current user names
import * as preact from "preact"
import * as server from "@app/server";
export async function fetch(route: string, prefix: string) {
return server.ListUsers({})
}
export function view(route: string, prefix: string, data: server.UserListResponse): preact.ComponentChild {
return <div>
<h3>Users</h3>
{data.AllUsernames.map(name => <div key={name}>{name}</div>)}
</div>
}
The fetch function now calls ListUsers
, which I haven't shown, but you can easily imagine how we added it.
The view function takes that initial response and just renders the list of names.
NOTE: We have to restart the server after defining new RPC functions in order to generate the typescript bindings.
When we run this we face a problem: because the server has restarted, the username list is cleared, but it was initialized to nil, so the response list will be null.
This is a weakness of the binding system: it does not set the type of slices to nullable. This is on purpose: I do not want to litter the code will nullable array types. It's borderline retarded. Instead, I just do a bit of extra work to ensure all lists in responses are not null. Sometimes I can miss some cases, but I see it as low risk. Ideally the JSON encoder never encodes a nil slice as a JSON null, but that day is not today.
For now I will just init usernames
to an actual empty slice.
That should do it, but it does not change the fact that the list is empty.
We can again just add names from the dev console. When we do it and reload the page, we see those names, indicating the TSX code shown above works!
Now we want to show a form where you add a new name and the list is auto refreshed. To do this we will need to store some state:
The current name input
The latest known user list
This state must be "stable" across rendering cycles, but we are:
Not using class components
Not using React Hooks
So how do we make a "stable" state object across rendering cycles? Caching
vlens ships with a simple caching system that lets you specify:
The cache key (usually a list of things)
The computation function if no hit for key
There happens to be no limit to how many keys you can cache. We don't have an LRU. We just expect you not to fill the cache with tons of things. Which is arguably not a great cache API, but it lets us rely on the cache to allow for creating "stable" objects that are "hooked" to other "stable" objects.
Here's our type definition for the state object:
type Form = {
data: server.UserListResponse
name: string
error: string
}
Now, we can create a function that retrieves a stable reference to it this way:
function getForm(data: server.UserListResponse): Form {
function create(): Form {
return {
data, name: "", error: "",
}
}
const key = [getForm, vlens.cacheById(data)]
return vlens.cacheGet(key, create)
}
When this is called for the first time with the given parameters, there will be nothing that matches the given key, so the create
inner function will be called to create the instance, and the reference to that instance will be stored in the cache.
Next time we call getForm with the same reference to data
, it will not create a new instance of Form
, instead, it will return a reference to the instance previously created.
This effectively creates a hook, but not in the React style. It hooks one object reference B to another object reference A. As long as the reference to A is stable, the reference to B will also be stable.
Now, since this pattern is so desirable, we have a specialized interface for it: declareHook
. It takes a function that receives parameters, and hooks the output to those parameters.
const useForm = vlens.declareHook((data: server.UserListResponse): Form => ({
data, name: "", error: ""
}))
This is functionally the same as the previous code: it create a function (we called it useForm
instead of getForm
here) that returns a reference hooked to its input.
Our plan is that when we submit the new name and get the update user list, we update the list in the Form object, and leave the original `data` object untouched, because it's what we got from the initial page load, and we would like to treat it as effectively immutable.
So now we add the input form, and this is a chance to introduce another powerful feature of vlens: binding input to fields without explicitly writing callbacks
<input type="text" {...events.inputAttrs(vlens.ref(form, "name"))} />
There are two features in this snippet:
vlens.ref
: an object that acts like a pointer to a field.events.inputAttrs
: set thevalue
andonInput
attributes to bind the input field to the givenref
The ref is, in reality, a poorly implemented 'pointer' from C. What we really want to do is say something like:
input({ .ref = &form.name })
It's just a bit too verbose because of the language and the underlying DOM API.
Now, just having this, we want a simple way to check that the code does indeed bind the input field to the form.name
field, so we just print it:
And here we confirm that it works:
Next, we need to add a button that, when clicked, calls the AddUser
proc on the server, and replace the username list with the one we get from the response.
Well, we can easily define a function that does exactly that:
async function onAddUserClicked(form: Form) {
let [resp, err] = await server.AddUser({Username: form.name})
if (resp) {
form.name = ""
form.data = resp
form.error = ""
} else {
form.error = err
}
}
But this function takes a form as a parameter; we can't just pass it to an onClick
.. we'd have to use a closure 🤔
The problem with closures in JSX attributes is they are not stable references, and to make good use of the virtual dom, we would really like to keep all the callback references stable.
vlens has just the right tool for that, again using the caching module. We have a utility that lets us return a stable closure reference!!
So instead of doing this:
<button onClick={() => onAddUserClicked(form)}>Add</button>
We do this:
<button onClick={vlens.cachePartial(onAddUserClicked, form)}>Add</button>
vlens.cachePartial(fn, a, b, c)
returns a stable reference to what you would get from calling fn.partial(a, b, c)
. However, for this to work properly, the input function itself must not be a closure that depends on variables in its environment with an unstable reference.
Now, one last thing to know about: because none of the variables we're working with has any special status in the context of the UI library, we have to explicitly tell the UI to refresh after we are done with the response:
`vlens.scheduleRedraw()` does not cause an immediate re-rendering. It just tells the system to re-render in the next animation frame. It's safe to call this as many time as you want per rendering-cycle. It's idempotent.
Here's testing the UI:
We can try adding some error checking for fun, but it's too trivial to cover in great detail here, and we can leave it as an exercise to the reader. For instance, we can check for duplicate entries, and refuse an entry if it already exists. We can enforce only alphanumeric characters and underscore, dash, and dot.
Now, more importantly, the next step is to persist the data to a database that survives server restarts.
But, I think we've covered enough ground in this episode already, so we'll save data persistence to the next episode.
Download the source code: EP002.zip
View the source code online: HandCraftedForum/tree/EP002