Primate

Grain

Primate runs Grain with WebAssembly compilation, strongly-typed validation, sessions, and server-side routing.

Setup

Install Grain

First, install Grain from the official website. Make sure the grain command is available in your PATH.

Install Module

npm install @primate/grain

Configure

import config from "primate/config";
import grain from "@primate/grain";

export default config({
  modules: [grain()],
});

Routes

Create Grain route handlers in routes using .gr files. Routes are compiled to WebAssembly and run in the JavaScript runtime.

// routes/hello.gr
module Hello

from "primate/request" include Request
from "json" include Json

use Request.{ type Request }
use Json.{ type Json }

provide let get = (request: Request) =>
  JsonObject([("message", JsonString("Hello, world!"))])

HTTP Methods

All standard HTTP methods are supported by providing the appropriate functions:

module Routes

from "primate/request" include Request
from "json" include Json

use Request.{ type Request }
use Json.{ type Json }

provide let get = (request: Request) =>
  JsonString("GET request")

provide let post = (request: Request) =>
  JsonString("POST request")

provide let put = (request: Request) =>
  JsonString("PUT request")

provide let delete = (request: Request) =>
  JsonString("DELETE request")

Request Handling

Query Parameters

Access query parameters through the request object:

// routes/query.gr
module Query

from "primate/request" include Request
from "map" include Map
from "option" include Option
from "json" include Json

use Request.{ type Request }
use Json.{ type Json }

provide let get = (request: Request) => {
  let query = Request.getQuery(request)
  match (Map.get("foo", query)) {
    Some(value) => JsonString(value),
    None => JsonString("foo missing")
  }
}

Request Body

Handle different body types based on content:

JSON Body

// routes/json.gr
module JsonRoute

from "primate/request" include Request
use Request.{ type Request, module Body }

provide let post = (request: Request) => Body.json(request)

Form Fields

// routes/form.gr
module Form

from "primate/request" include Request
from "map" include Map
from "json" include Json

use Request.{ type Request, module Body, module BodyField }
use Json.{ type Json }

provide let post = (request: Request) => {
  let fields = Body.fields(request)

  let pairs = Map.reduce((acc, key, value) => {
    let stringValue = BodyField.string(value)
    [(key, JsonString(stringValue)), ...acc]
  }, [], fields)

  JsonObject(pairs)
}

Text Body

// routes/text.gr
module Text

from "primate/request" include Request

use Request.{ type Request }

provide let post = (request: Request) => Response.text(request)

Binary Data

// routes/binary.gr
module Binary

from "primate/request" include Request
from "json" include Json
from "bytes" include Bytes
from "uint8" include Uint8

use Request.{ type Request, module Body, type Blob }
use Json.{ type Json }

provide let post = (request: Request) => {
  let { mimeType, bytes }: Blob = Body.blob(request)

  let byteLength = Bytes.length(bytes)
  let mut values = []

  for (let mut i = byteLength - 1; i >= 0; i -= 1) {
    let value = JsonNumber(Uint8.toNumber(Bytes.getUint8(i, bytes)))
    values = [value, ...values]
  }

  JsonObject([
    ("type", JsonString(mimeType)),
    ("size", JsonNumber(byteLength)),
    ("head", JsonArray(values)),
  ])
}

File Uploads

Handle multipart file uploads:

// routes/upload.gr
module Upload

from "primate/request" include Request
from "map" include Map
from "option" include Option
from "json" include Json
from "bytes" include Bytes

use Request.{ type Request, module Body, module BodyField, type File }
use Json.{ type Json }

provide let post = (request: Request) => {
  let fields = Body.fields(request)

  // Process regular fields
  let regularFields = Map.reduce((acc, key, value) => {
    match (value) {
      BodyFieldString(str) => [(key, JsonString(str)), ...acc],
      _ => acc
    }
  }, [], fields)

  // Process file fields
  let fileFields = Map.reduce((acc, key, value) => {
    match (value) {
      BodyFieldFile({ name, mimeType, bytes }) => {
        let content = Bytes.toString(bytes)
        let fileInfo = JsonObject([
          ("name", JsonString(name)),
          ("type", JsonString(mimeType)),
          ("size", JsonNumber(Bytes.length(bytes))),
          ("content", JsonString(content))
        ])
        [(key, fileInfo), ...acc]
      },
      _ => acc
    }
  }, [], fields)

  JsonObject([
    ("fields", JsonObject(regularFields)),
    ("files", JsonObject(fileFields))
  ])
}

Responses

Plain Data

Return JSON data by constructing Json values:

provide let get = (request: Request) =>
  JsonObject([("name", JsonString("Donald"))])

provide let get = (request: Request) =>
  JsonString("Hello, world!")

provide let get = (request: Request) =>
  JsonArray([
    JsonObject([("name", JsonString("Donald"))]),
    JsonObject([("name", JsonString("Ryan"))])
  ])

Views

Render components with props using the Response module:

// routes/view.gr
module View

from "primate/request" include Request
from "primate/response" include Response
from "json" include Json

use Response.{ type Response }
use Request.{ type Request }

provide let get = (request: Request) => Response.view(
  "index.html",
  props = JsonObject([("hello", JsonString("world"))]),
)

With options:

provide let get = (request: Request) => Response.view(
  "index.html",
  props = JsonObject([("hello", JsonString("world"))]),
  partial = true,
)

Redirects

Redirect to another route:

// routes/redirect.gr
module Redirect

from "primate/request" include Request
from "primate/response" include Response

use Response.{ type Response }
use Request.{ type Request }

provide let get = (request: Request) => Response.redirect("/redirected")

With custom status code:

provide let get = (request: Request) =>
  Response.redirect("/redirected", status = Some(MovedPermanently))

Error Responses

Return error responses:

// routes/error.gr
module Error

from "primate/request" include Request
from "primate/response" include Response

use Response.{ type Response }
use Request.{ type Request }

provide let get = (request: Request) => Response.error()

With custom error options:

provide let get = (request: Request) =>
  Response.error(body = Some("Custom error message"))

Sessions

Manage user sessions with the session module:

// routes/session.gr
module Session

from "json" include Json
from "primate/request" include Request
from "primate/response" include Response
from "primate/session" include Session
from "option" include Option

use Session.{ type Session }
use Request.{ type Request }
use Response.{ type Response }

provide let get = (request: Request) => {
  // Create a session
  Session.create(JsonObject([("foo", JsonString("bar"))]))

  // Get session data
  let session = Option.expect("Session must exist", Session.get())
  Response.json(session.data)
}

Session Methods

Database Operations

Primate Grain includes built-in database operations through the Store module:

// routes/db.gr
module Database

from "json" include Json
from "primate/request" include Request
from "primate/store" include Store
from "result" include Result

use Json.{ type Json }
use Request.{ type Request }

let UserStore = Result.expect("User Store must exist", Store.store("User"))

provide let get = (request: Request) => {
    // Clear existing data
    Result.expect("Clear must succeed", Store.clear(UserStore))

    // Insert new records
    let user1 = Result.expect("Insert must succeed",
      Store.insert(UserStore, JsonObject([
        ("age", JsonNumber(30)),
        ("name", JsonString("Donald"))
      ])))

    let user2 = Result.expect("Insert must succeed",
      Store.insert(UserStore, JsonObject([
        ("age", JsonNumber(40)),
        ("name", JsonString("Ryan"))
      ])))

    // Get count
    let count = Result.expect("Count must succeed", Store.count(UserStore))

    JsonObject([("count", JsonNumber(count))])
}

Store Operations

Using JSPI

Grain stores use JSPI, JavaScript promise integration, to be able to use async code as if it were sync from Wasm. This currently only support in Node (with a flag) and Deno.

In Node, instead of npx primate, you currently need to run node --experimental-wasm-jspi node_modules/primate/lib/bin.js.

Deno can be used normally - deno -A run npm:primate.

WebSocket Support

Create WebSocket endpoints for real-time communication:

// routes/websocket.gr
module Ws

from "primate/request" include Request
from "primate/response" include Response
from "primate/websocket" include WebSocket

use Response.{ type Response }
use Request.{ type Request }
use WebSocket.{ type SocketMessage, type WebSocket, send }

provide let get = (req: Request) => {
  Response.ws(
    open = Some((_socket) => {
      print("WebSocket opened!")
    }),
    message = Some((socket: WebSocket, payload: SocketMessage) => {
      // Echo the message back
      send(socket, payload)
    }),
  )
}

WebSocket Methods

Grain Language Features

Pattern Matching

Grain's pattern matching makes request handling elegant:

provide let post = (request: Request) => {
  match (request.body) {
    BodyString(text) => JsonString("Received text: " ++ text),
    BodyJson(json) => json,
    BodyNull => JsonString("No body provided"),
    _ => JsonString("Unsupported body type")
  }
}

Option and Result Types

Handle errors safely with Grain's type system:

provide let get = (request: Request) => {
  let query = Request.getQuery(request)
  match (Map.get("id", query)) {
    Some(id) => {
      match (Store.get(UserStore, id)) {
        Ok(user) => user,
        Err(_) => JsonObject([("error", JsonString("User not found"))])
      }
    },
    None => JsonObject([("error", JsonString("ID parameter required"))])
  }
}

Immutable Data Structures

Grain uses immutable data structures by default:

provide let post = (request: Request) => {
  let baseData = JsonObject([("created", JsonString("2024-01-01"))])
  let requestData = Body.json(request)

  // Combine data immutably
  match (requestData) {
    JsonObject(pairs) => {
      JsonObject([("created", JsonString("2024-01-01")), ...pairs])
    },
    _ => baseData
  }
}

Configuration

Option Type Default Description
command string grain Grain compiler command
fileExtension string ".gr" Associated file extension
includeDirs array [] Additional include directories
noPervasives bool false Disable pervasive imports
stdlib string null Custom standard library path
strictSequence bool false Enable strict sequence checking

Example

import grain from "@primate/grain";
import config from "primate/config";

export default config({
  modules: [
    grain({
      // use custom grain command
      command: "/usr/local/bin/grain",
      // use `.grain` as associated file extension
      fileExtension: ".grain",
      // add custom include directories
      includeDirs: ["./lib/grain"],
      // enable strict sequence checking
      strictSequence: true,
    }),
  ],
});

Grain Module Structure

Each route file should follow this structure:

module ModuleName

// Imports
from "primate/request" include Request
from "primate/response" include Response
from "json" include Json

// Type annotations
use Request.{ type Request }
use Json.{ type Json }

// Exception definitions (if needed)
exception CustomError

// Helper functions
let helperFunction = (param) => {
  // implementation
}

// Route handlers (must be provided)
provide let get = (request: Request) => {
  // implementation
}

provide let post = (request: Request) => {
  // implementation
}

Resources

Previous
Go
Next
Python