Skip to content

Latest commit

 

History

133 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SendScript

Serialize and execute composable JavaScript function calls with JSON.

NPM 100% Code Coverage Standard Code Style License

Features

  • Composable function calls - Combine multiple functions into a single request
  • Write ordinary JavaScript - Programs serialize to JSON and execute as written
  • Type-safe - Works great with TypeScript for client-side type checking
  • Zero dependencies - Lightweight core library
  • Async/await support - Seamless async operations within a single payload
  • Custom serializers - Support for Date, Map, Set, BigInt, and more
  • Transport agnostic - Use HTTP, WebSockets, or any other communication method

Installation

Install SendScript from npm:

npm install sendscript

Quick Start

Here's the simplest example to get started with SendScript:

import Stringify from 'sendscript/stringify.mjs'
import Parse from 'sendscript/parse.mjs'
import references from 'sendscript/references.mjs'

// Define your functions
const functions = {
  greet: (name) => `Hello, ${name}!`,
}

// Create client-side stubs
const { greet } = references(['greet'])

// Serialize a program on the client
const stringify = Stringify()
const program = stringify(greet('World'))

// Parse and execute on the server
const parse = Parse(['greet'], functions)
const result = parse(program) // "Hello, World!"

This demonstrates the core concept: write a program on the client, serialize it to JSON, send it to the server, and execute it exactly as written.

Introduction

There has been interest in improving APIs by allowing aggregations in a single request. Examples include

  • JSON-RPC which allows you to do multiple requests but it does not allow you to compose the return value of one endpoint to be the input/arguments of another.

  • GraphQL is very cool but also introduces a new language and the tooling that is required to wield it.

Why SendScript?

Unlike JSON-RPC (which doesn't support function composition) or GraphQL (which requires learning a new language), SendScript lets you write ordinary JavaScript/TypeScript that gets serialized and executed on the server exactly as written.

How It Works

What SendScript attempts is to allow for very expressive queries and mutations to be performed that read and write like ordinary JS. That means that the queries and complete programs that are sent to the server from a client can also just run on the server as is. The only limitation being the serialization which by default is limited by JSON and could be extended by using more advanced (de)serialization libraries.

SendScript produces an intermediate JSON representation of the program. Let's see what that looks like.

import Stringify from 'sendscript/stringify.mjs'
import references from 'sendscript/references.mjs'

const { add } = references(['add'])
const stringify = Stringify()

console.log(stringify(add(1,2)))
["call",["ref","add"],[["leaf","1"],["leaf","2"]]]

We can then parse that JSON and it will evaluate down to a value.

import Parse from 'sendscript/parse.mjs'

const module = {
  add(a, b) {
    return a + b
  }
}

const parse = Parse(['add'], module)

const program = '["call",["ref","add"],[1,2]]'

console.log(parse(program))
3

SendScript does more than a simple function call. It supports function composition and even await.

This package is nothing more than the absolute core of sendscript. It includes:

  • The references function to create stubs to write the programs.
  • stringify which takes the program and returns a JSON string.
  • parse which takes the stringify JSON string and a real module and returns the result.

Note: SendScript works in Node.js and browsers. The core library is framework-agnostic and transport-agnostic — use HTTP, WebSockets, or any other communication method that suits your needs.

Future enhancements may include support for more complex (de)serializers, improved error handling, and deeper integration of client functions with SendScript programs. Contributions and feedback are welcome.

Socket example

SendScript leaves it up to you to choose HTTP, web-sockets or any other method of communication between servers and clients that best fits your needs.

For this example we'll use socket.io.

Module

We write a simple module.

// ./example/math.mjs

export const add = (a, b) => a + b
export const square = (a) => a * a

Server

Here's a socket.io server that runs SendScript programs.

// ./example/server.socket.io.mjs

import { Server } from 'socket.io'
import Parse from 'sendscript/parse.mjs'
import * as math from './math.mjs'

const schema = Object.keys(math)
const parse = Parse(schema, math)
const server = new Server()
const port = process.env.PORT || 3000

server.on('connection', (socket) => {
  socket.on('message', async (program, callback) => {
    try {
      const result = parse(program)
      callback(null, result) // Pass null as the first argument to indicate success
    } catch (error) {
      callback(error) // Pass the error to the callback
    }
  })
})

server.listen(port)
process.title = 'sendscript'

Client

Now for a client that sends a program to the server.

// ./example/client.socket.io.mjs

import socketClient from 'socket.io-client'
import Stringify from 'sendscript/stringify.mjs'
import references from 'sendscript/references.mjs'
import assert from 'node:assert'

const { add, square } = references(['add', 'square'])
const stringify = Stringify()

const port = process.env.PORT || 3000
const client = socketClient(`http://localhost:${port}`)

const send = (program) => {
  return new Promise((resolve, reject) => {
    client.emit('message', stringify(program), (error, result) => {
      error ? reject(error) : resolve(result)
    })
  })
}

// The program to be sent over the wire
const program = square(add(1, add(add(2, 3), 4)))

const result = await send(program)

console.log('Result: ', result)

assert.equal(result, 100)

process.exit(0)

Now we run this server and a client script.

set -e

# Run the server
node ./example/server.socket.io.mjs&

# Run the client example
node ./example/client.socket.io.mjs

pkill sendscript
Result:  100

Repl

Sendscript ships with a bare-bones (no-dependencies) node-repl script. One can run it by simply typing sendscript in their console.

Use the DEBUG='*' to enable all logs or DEBUG='sendscript:*' for printing only sendscript logs.

Promises

.then / .catch

Supported since v2.3.

const getOrCreatePost = send(createPost(title).catch(createPost(title)))

You will likely need to define better helpers that makes it safer to handle rejections and work with promises. It is however sensible to have this basic behavior for the sendscript DSL and parser.

await

By default, await api.someMethod(...) still creates a SendScript await stub and keeps the result serializable with stringify(). It does not automatically send anything over the network.

const api = references(['add'])
const program = api.add(1, 2)

Stringify()(program)
// => "[\"call\",[\"ref\",\"add\"],[1,2]]"

If you want native await to cross the transport boundary, pass an onAwait handler when creating the references:

import Stringify from 'sendscript/stringify.mjs'
import references from 'sendscript/references.mjs'

const stringify = Stringify()

const api = references(['add'], (program) => {
  return fetch('/api', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: stringify(program),
  }).then((response) => response.json())
})

const result = await api.add(1, 2)

The callback receives the generated SendScript program, and you decide how it is transported or executed.

TypeScript

Using SendScript with TypeScript enables type-safe client-side code. Your client can have full IDE autocomplete and compile-time type checking when calling server functions.

Server-Side Module

Define your API as a TypeScript module on the server:

cat ./example/typescript/math.ts
/**
 * Server-side math module with typed functions
 * These functions will be called from the client through SendScript
 */

export const add = (a: number, b: number): number => a + b

export const square = (a: number): number => a * a

Client-Side Type Stub

Create a client-side file that mirrors your server types using the as typeof casting pattern:

cat ./example/typescript/math.client.ts
/**
 * Client-side type-safe stubs for the math API
 * 
 * This file creates typed references that mirror the server's functions.
 * The 'as typeof mathTypes' cast gives us full TypeScript support and IDE autocomplete.
 */

import type * as mathTypes from './math.ts'
import references from 'sendscript/references.mjs'

// Create type-safe stubs - this tells TypeScript that 'add' and 'square' 
// have the same signatures as the server functions
export default references(['add', 'square']) as typeof mathTypes

The as typeof mathTypes type assertion gives your client-side references the exact same types as your server module. This means:

  • Full IDE autocomplete for function names and parameters
  • Compile-time type checking - catch errors before runtime
  • Your client code looks identical to regular JavaScript calls

Using Typed References

Now on your client, you have complete type safety:

cat ./example/typescript/client.ts
/**
 * Client-side usage with type-safe SendScript
 */

import math from './math.client.ts'
import Stringify from 'sendscript/stringify.mjs'

const stringify = Stringify()

/**
 * Send a SendScript program to the server
 * 
 * TypeScript knows that the return type matches the program's return type.
 * In this case, square(add(1, 2)) returns a number, so T is number.
 */
async function send<T>(program: T): Promise<T> {
  return (await fetch('/api', {
    method: 'POST',
    body: stringify(program)
  })).json()
}

// TypeScript provides full autocomplete for math.add and math.square
// It knows they take numbers and return numbers
const result = await send(math.square(math.add(1, 2)))
console.log(result) // 9

TypeScript knows the exact parameter types and return types for every function call.

Generating API Documentation

You can use typedoc to automatically generate documentation from your TypeScript types:

npx typedoc --plugin typedoc-plugin-markdown --out ./docs ./example/typescript/math.ts
�[96m[info]�[0m Loaded plugin typedoc-plugin-markdown
�[96m[info]�[0m markdown generated at ./docs

This generates markdown docs that can be shared with API consumers. See the generated docs.

[!IMPORTANT] Type vs. Runtime Values

Type casting on the client side improves the development experience, but remember:

  • The actual serialized JSON may differ from the static types
  • Runtime values depend on serialization/deserialization
  • Always validate user input on the server using schema validation (e.g., Zod)
  • Use the types as a contract, not a guarantee

Schema and Nested Modules

Sendscript allows you to define your API as a nested object of functions, making it easy to organize your DSL into modules and submodules. Each function is instrumented so that when serialized, it produces a structured reference that can be safely sent and executed elsewhere.

Defining a Nested Module

You can define a schema as an array of function names

const schema = [
  'help',
  'version',

  ['math', [
    'add',
    'sub'
  ]],
  ['vector', [
    'add',
    'multiply'
  ]],
  ['utils', [
    'identity',
    'always'
  ]],
})

Functions are referenced via their path in the module tree:

const { math, vector } = references(schema)

math.add(1, vector.length(vector.multiply([1, 2], 3)))

Validation (using Zod)

SendScript focuses on program serialization and execution. For runtime input validation, you can use Zod.

Validating structured input

const userSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  roles: z.array(z.string()),
})

export function createUser(user) {
  userSchema.parse(user)

  return { success: true }
}

Benefits:

  • Ensures arguments match expected types and shapes.
  • Throws structured errors that can be propagated to clients.
  • Works with TypeScript for automatic type inference.

Leaf Serializer

By default, SendScript uses JSON for serialization, which limits support to primitives and plain objects/arrays. To support richer JavaScript types like Date, RegExp, BigInt, Map, Set, and undefined, you can provide custom serialization functions.

The stringify function accepts an optional leafSerializer parameter, and parse accepts an optional leafDeserializer parameter. These functions control how non-SendScript values (leaves) are encoded and decoded.

Example with superjson

Here's how to use superjson to support extended types:

import SuperJSON from 'superjson'
import Stringify from 'sendscript/stringify.mjs'
import references from 'sendscript/references.mjs'
import Parse from 'sendscript/parse.mjs'

const leafSerializer = (value) => {
  if (value === undefined) return JSON.stringify({ __undefined__: true })
  return JSON.stringify(SuperJSON.serialize(value))
}

const leafDeserializer = (text) => {
  const parsed = JSON.parse(text)
  if (parsed && parsed.__undefined__ === true) return undefined
  return SuperJSON.deserialize(parsed)
}

const schema = ['processData']

const { processData } = references(schema)
const stringify = Stringify(leafSerializer)

// Program with Date, RegExp, and other types
const program = {
  createdAt: new Date('2020-01-01T00:00:00.000Z'),
  pattern: /foo/gi,
  count: BigInt('9007199254740992'),
  items: new Set([1, 2, 3]),
  mapping: new Map([
    ['a', 1],
    ['b', 2],
  ]),
}

// Serialize with custom leaf serializer
const json = stringify(processData(program))

// The environment
const env = {
  processData: (data) => ({
    success: true,
    received: data,
  }),
}

// Parse with custom leaf deserializer
const parse = Parse(schema, env, leafDeserializer)

const result = parse(json)

The leaf wrapper format is ['leaf', serializedPayload], making it unambiguous and safe from colliding with SendScript operators.

Limitations

The stubs the references function returns are limited in the way they can be used. This has to do with the fact that they are to be serialized.

Chaining APIs

Currently you cannot do chaining APIs. lib().doThis().doThat(). It likely is possible to implement but will require more extensive schemas and obviously more code to create the AST.

Callbacks

SendScript supports basic callback-style usage, such as passing a function as an argument or using a callback to flip arguments into the generated program.

const api = references(['map', 'add'])

const result = api.map((value) => api.add(value, 1))([1, 2, 3])

This is meant for composing SendScript programs, not for executing arbitrary client-side logic at runtime.

[!WARNING] Mixing client and server functions can be confusing, because the callback may be used to build a program rather than execute immediately. Keep callback logic simple and deterministic.

Error handling

Sendscript does not have builtin tools to handle errors. You can write your own utilities for that or use things like [Ramda's tryCatch][tryCatch]. This will likely never be supported as JS try catch does not have a return value. It might make sense to wrap everything that can throw in promises which is easy to do with async functions.

Tests

Tests with 100% code coverage.

npm t -- -R silent
npm t -- report text-summary

=============================== Coverage summary ===============================
Statements   : 100% ( 516/516 )
Branches     : 100% ( 157/157 )
Functions    : 100% ( 23/23 )
Lines        : 100% ( 516/516 )
================================================================================

Formatting

Standard because no config.

npx standard

Changelog

The changelog is generated using the useful auto-changelog project.

npx auto-changelog -p

License

See the LICENSE.txt file for details.

Issues

See issues for roadmap and known bugs.

About

RPC and no-build with composable function calls in a single payload.

Topics

Resources

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages