Services

How a program reaches a service, what a call gives back, and the services for time, JSON, your own pages, web requests and the run.

A service is an instance named after itself, with Parent nil, living as long as the run. A System project has nine: ProcessService, FileService, JsonService, TimeService, PageService, HttpService, RunService, ProgramService and EncodingService (the last two are in the class reference). Call members with a colon: TimeService:Now() (Instances).

What a service hands you (a started program, a timer, a page server) is a handle, an instance that is a child of that service. Destroy ends it; the end of the run destroys whatever is left.

Each example is Main.shot of a one-file project, Demo.ssp (shot write takes the text on stdin), and checks with 0 errors unless it shows them:

shot new Demo.ssp
shot write Main.shot --project Demo.ssp
shot check --project Demo.ssp
shot run Demo.ssp

Reach a service

In a .shot file, one line:

use ProcessService

In any file, plain Luau included, one require:

local ProcessService = require("@shotsystem/ProcessService")

use tries the project's own files first: a project file called ProcessService wins. A name that is no service and no project file is an error in shot check:

Demo.ssp/Main.shot:1   use NopeService: no NopeService in this folder or any folder above it (use looks at each folder's own files and folders, up to the project root)

and stops a run there (exit code 1):

error: Main.shot:1: use NopeService: no NopeService in this folder or any folder above it (use looks at each folder's own files and folders, up to the project root)
  at Main.shot:1

Both forms are typed: shot check reports a dot call, a missing member and a result that may be nil:

use ProcessService
use TimeService

local now = TimeService.Now()
local timer = TimeService:After(5, function() end)
timer:Cancel()
print(ProcessService:StartAsync("python").Pid)

gives

Demo.ssp/Main.shot:4:13   TypeError: This function must be called with self. Did you mean to use a colon instead of a dot?
Demo.ssp/Main.shot:6:1   TypeError: Key 'Cancel' not found in external type 'Timer'
Demo.ssp/Main.shot:7:7   TypeError: Value of type 'ProcessHandler?' could be nil

Run anyway, the dot call stops it:

error: Main.shot:4: TimeService.Now is a method: call it with a colon, TimeService:Now(...)
  at Main.shot:4
  • A service can't be destroyed: TimeService can't be destroyed: a service lives as long as the run.
  • Any member of a destroyed handle is an error: ProcessHandler "python" was destroyed.

What a call gives back

  • A member that waits is named ...Async; the others never wait.
  • Each callback you hand a service runs in its own thread, so it may call ...Async members.
  • A mistake in the code is an error: a wrong argument, a value JSON can't hold, an unknown time code.
  • A failure of the world returns nil (or false) and why: one line starting with the operation and what it was about.
use JsonService
use ProcessService

-- a failure of the world: nil and why
local result, why = ProcessService:RunAsync("no-such-program")
print(tostring(result) .. ", " .. tostring(why))

-- a mistake in the code: an error
local ok, problem = pcall(function()
    return JsonService:Encode({ Run = print })
end)
print(tostring(ok) .. ", " .. tostring(problem))

prints

nil, StartAsync no-such-program: program not found
false, Main.shot:10: JsonService:Encode: unsupported value type `function`

RunAsync's why names StartAsync, which it calls. Uncaught, a mistake stops the run with exit code 1:

use JsonService

local reading = { Run = print }
print(JsonService:Encode(reading))

prints

error: Main.shot:4: JsonService:Encode: unsupported value type `function`
  at Main.shot:4

To stop on a failure, wrap the call in assert; the error says why:

use ProcessService

local child = assert(ProcessService:StartAsync("no-such-program"))

stops the run with

error: Main.shot:3: StartAsync no-such-program: program not found
  at Main.shot:3

An error in a callback is printed and the program goes on, but the run ends with exit code 1:

use TimeService

TimeService:After(0.1, function()
    error("broken in a timer")
end)
TimeService:WaitAsync(0.3)
print("the program went on")

prints

error: Main.shot:4: broken in a timer
  at Main.shot:4
the program went on
1 error in callbacks

Signals (Line, Exited, Connected, Message, Stopping, ...) have Connect, Once and WaitAsync(timeout?), nil on a timeout (Instances, EVENTS AND SIGNALS).

What keeps a run going

A run ends when the code is done and nothing it started is live. Live until they end or are destroyed: timers, page servers, and started programs you listen to (Line, Exited or WaitAsync).

use TimeService

TimeService:After(1, function()
    print("the timer, a second after the last line")
end)
print("the last line of code")

prints

the last line of code
the timer, a second after the last line
  • An Every or a page server you never destroy keeps a run going for good. RunService:Quit ends a run at once.
  • When a run ends, programs it started that still run are stopped, with everything they started.
  • Relative paths and working folders start at the project file's folder (RunService.ProjectFolder), wherever you ran shot run.

Files and folders

FileService reads, writes, lists, copies, moves and recycles files and folders through PathHandlers, opens files for ranged work and follows a growing file: see FileService.

Programs

ProcessService starts programs on this computer, streams their lines, writes their input, waits for them and stops them with everything they started. StartAsync gives a ProcessHandler named after its program; RunAsync runs one to its end. See ProcessService.

Time and timers

TimeService: the time, waits, timers and times as text. A time is seconds since 1970, milliseconds as the fraction.

TimeService:Now() -> number                                 seconds since 1970
TimeService:Clock() -> number                               seconds since an arbitrary start, for measuring
TimeService:WaitAsync(seconds : number) -> number           waits (at least one frame), gives how long it waited
TimeService:After(seconds : number, callback : () -> ()) -> Timer    calls it once
TimeService:Every(seconds : number, callback : () -> ()) -> Timer    calls it every period, the first one period from now
TimeService:Format(time : number?, format : string?) -> string      local time as text; nil time: now
TimeService:Iso(time : number?) -> string                   UTC to the second, 1970-01-01T00:00:00Z; nil time: now
TimeService:CreateTimer(interval : number, repeats : boolean?) -> Timer    stopped until its Start
TimeService:CreateAlarm(at : number) -> Timer               fires once at a clock time

A Timer is a child of TimeService named "After 0.2" or "Every 0.1". Destroy stops it.

timer.Interval : number             seconds to the next fire
timer.Repeats : boolean
timer.Running : boolean
timer.TimesFired : number
timer.Fired : signal (at : number)  at: the clock time it fired
timer:Start(), timer:Stop()
  • A timer has no Cancel: Cancel is not a valid member of Timer "TimeService.After 5". Destroy it.
  • A fired After timer is destroyed by TimeService: reading it is an error (the example's last line).
  • Format writes local time, Iso UTC. Format's default is "%Y-%m-%d %H:%M:%S". Its codes are below, and a -, _ or 0 after the % works on any of them; any other code is an error, TimeService:Format: %q is not a time code:
%Y %C %y %m %b %B %h %d %e %a %A %w %u %U %W %G %g %V %j %D %x %F %v %H %k %I %l %P %p %M %S %f %R %T %X %r %Z %z %c %s %t %n
%-d %_d %0d   %.f %.3f %.6f %.9f   %:z %::z %:::z   %+ %%
  • A program has task.spawn, task.defer and task.cancel but no task.wait, wait or tick; shot check names the replacement:
Demo.ssp/Main.shot:1:1   task.wait is Roblox's: write TimeService:WaitAsync(seconds) (use TimeService)

Example:

use TimeService

print("time 0 in UTC: " .. TimeService:Iso(0))
print("now, local: " .. TimeService:Format())

local once = TimeService:After(0.2, function()
    print("After: fired once")
end)
print(once.ClassName .. " " .. once.Name .. ", a child of TimeService: " .. tostring(once.Parent == TimeService))
local cancelled = TimeService:After(0.2, function()
    print("never printed")
end)
cancelled:Destroy()

local ticks = 0
local every = TimeService:Every(0.1, function()
    ticks += 1
end)
local waited = TimeService:WaitAsync(0.55)
every:Destroy()
print(string.format("waited %.1f s: Every ticked %d times", waited, ticks))
print("the fired timer: " .. tostring(select(2, pcall(function()
    return once.Parent
end))))

prints (the second line is your date and time)

time 0 in UTC: 1970-01-01T00:00:00Z
now, local: 2026-10-20 01:18:51
Timer After 0.2, a child of TimeService: true
After: fired once
waited 0.6 s: Every ticked 5 times
the fired timer: Main.shot:23: Timer "After 0.2" was destroyed

JSON

JsonService turns a value into JSON text and back. Neither call waits.

JsonService:Encode(value : any, pretty : boolean?) -> string       pretty: two spaces, one member per line
JsonService:Decode(text : string) -> (any, string?)
  • Keys come out sorted.
  • A table with exactly the keys 1..n is an array; any other is an object with string keys, so a gap stays: { [1] = "a", [3] = "c" } is {"1":"a","3":"c"}.
  • An empty table is {}, so an empty list you send is {} (WEB REQUESTS has one). Only a decoded [] is written back as [].
  • null decodes to nil: the field is missing.
  • Text that is not JSON comes from outside: Decode gives nil and why.
  • A value JSON can't hold is an error:
JsonService:Encode: unsupported value type `function`
JsonService:Encode: a table inside itself (a cycle)
JsonService:Encode: a key that is a boolean (keys are strings or numbers)

Example:

use JsonService

local reading = { Station = "north", Celsius = 21.5, Hours = { 9, 12 } }
print(JsonService:Encode(reading))
print(JsonService:Encode(reading, true))
print(JsonService:Encode({ [1] = "a", [3] = "c" }) .. " " .. JsonService:Encode({}))
print(JsonService:Encode((JsonService:Decode("[]"))) .. " " .. JsonService:Encode((JsonService:Decode("{}"))))

local back = JsonService:Decode('{"Celsius": 18, "Note": null}')
print("Celsius " .. back.Celsius .. ", Note " .. tostring(back.Note))

local bad, why = JsonService:Decode("{ not json")
print(tostring(bad) .. ", " .. tostring(why))

prints

{"Celsius":21.5,"Hours":[9,12],"Station":"north"}
{
  "Celsius": 21.5,
  "Hours": [
    9,
    12
  ],
  "Station": "north"
}
{"1":"a","3":"c"} {}
[] {}
Celsius 18, Note nil
nil, Decode: key must be a string at line 1 column 3

Your own pages

PageService serves your program's own pages on this computer only, with a live channel to every open page.

PageService:ServeAsync(options : PageOptions?) -> (Page?, string?)

PageOptions = { Folder : string?, Pages : {[string] : string}?, Port : number? }
PageRequest = { Method : string, Path : string, Query : {[string] : string}, Body : string }
PageResponse = { Status : number?, Body : string?, Type : string? }

A Page, a child of PageService named after its port ("page 47200"), serves until destroyed or the run ends. Below, <page> stands for its address, which shot prints in full with the port.

page.Url : string                   <page>/?k=<the page's key>
page.Port : number
page.Connected : signal (client : PageClient)            a page opened the channel
page.Message : signal (client : PageClient, text : string)
page:Send(text : string)            to every open page
page:Clients() -> {PageClient}
page:Handle(route : string, callback : (request : PageRequest) -> (PageResponse | string))
page:Destroy()                      stops serving

PageClient = { Id : number, Send : (self, text : string) -> (), Closed : signal () }
  • Folder serves a folder's files (index.html for /). Pages serves texts by route; / and routes without an extension are HTML, others go by extension. Port is the first port tried (47200 when nil), then the next free one: read page.Port.
  • /ui/common/ serves the project window's look (theme.css, ...), so a page can match the window (The window).
  • Handle answers an exact path ("/notes"), any method (request.Method). A route ending in /* answers what under it nothing else does, with the whole path in request.Path.
  • Its callback returns a string (200, text/plain) or { Status?, Body?, Type? }. An error in it answers 500 and is a callback error (WHAT A CALL GIVES BACK).
  • page.Url ends in ?k= and the page's key. Handle routes and the live channel answer a request without it with 401, this route needs the page's key (X-Shot-Key, or ?k= from page.Url); files and Pages texts don't. Send it as the X-Shot-Key header or ?k=, and build addresses from the part of page.Url before ?.
  • A PageClient has no Close: a channel closes when its page is closed or goes, and client.Closed fires.

The page's side of the channel is one script:

<script src="/shotsystem/live.js"></script>

ShotLive.on(fn)          hears each text the program sends
ShotLive.send(text)      sends one back
ShotLive.onState(fn)     hears "open" and "closed"

It reconnects by itself and adds the key to the page's requests; open the page by page.Url.

Example: open the address it prints in a browser on the same computer.

use PageService

local HTML = [[<!doctype html>
<html>
<head><meta charset="utf-8"><title>Station</title></head>
<body>
    <pre class="lines"></pre>
    <script src="/shotsystem/live.js"></script>
    <script>
        const lines = document.querySelector(".lines");
        ShotLive.on((text) => {
            lines.textContent += text + "\n";
            ShotLive.send("got " + text);
        });
    </script>
</body>
</html>
]]

local page = assert(PageService:ServeAsync({ Pages = { ["/"] = HTML } }))
print(page.ClassName .. " " .. page.Name .. ", a child of PageService: " .. tostring(page.Parent == PageService))
print("open " .. page.Url)

local client = page.Connected:WaitAsync(60)
if client then
    print("a page opened the channel")
    client:Send("hello page")
    local _, answer = page.Message:WaitAsync(10)
    print("the page answered: " .. tostring(answer))
else
    print("no page opened in a minute")
end
page:Destroy()
print("stopped")

prints, once a browser opened the address (the key differs each run):

Page page 47200, a child of PageService: true
open <page>/?k=28eb1255ec5090421ae3600a59f25134
a page opened the channel
the page answered: got hello page
stopped

Web requests

HttpService sends web requests, with a method, headers and a body.

HttpService:FetchAsync(request : HttpRequest) -> (HttpResponse?, string?)
HttpService:GetTextAsync(url : string, headers : {[string] : string}?) -> (string?, string?)
HttpService:PostTextAsync(url : string, body : string, contentType : string?, headers : {[string] : string}?) -> (string?, string?)

HttpRequest = { Url : string, Method : string?, Headers : {[string] : string}?, Body : string? }
HttpResponse = { StatusCode : number, StatusMessage : string, Success : boolean, Headers : {[string] : string}, Body : string }
  • FetchAsync gives any status as an answer (Success true for a 2xx), and nil and why only when nothing answered or the URL is bad: FetchAsync not a url: bad argument #1: relative URL without a base. Method defaults to GET.
  • GetTextAsync and PostTextAsync give the body of a 2xx answer, else nil and why with the status and the body's start: GetTextAsync <page>/nowhere: status 404: no page /nowhere.
  • PostTextAsync sends application/json unless you give contentType.
  • Roblox's RequestAsync, GetAsync and PostAsync throw on a failure; these return nil and why, hence the other names. The answer's fields are Roblox's.

Example: a page server of the same program is the server, so nothing leaves this computer.

use HttpService
use JsonService
use PageService
use TimeService

-- the server: a page of this program with one route of its own
local page = assert(PageService:ServeAsync())
local notes : {string} = {}
page:Handle("/notes", function(request)
    if request.Method == "POST" then
        table.insert(notes, request.Body)
        return { Status = 201, Body = "saved note " .. #notes }
    end
    return { Body = JsonService:Encode(notes), Type = "application/json" }
end)

-- page.Url ends in ?k=<key>: a route needs the key
local base = string.match(page.Url, "^[^?]*") or ""
local key = { ["X-Shot-Key"] = string.match(page.Url, "k=(%w+)") or "" }

print("before: " .. tostring((HttpService:GetTextAsync(base .. "notes", key))))
for _, note in { "rain at noon", "wind from the west" } do
    print(HttpService:PostTextAsync(base .. "notes", note, "text/plain", key))
end
print("after: " .. tostring((HttpService:GetTextAsync(base .. "notes", key))))

local answer = HttpService:FetchAsync({ Url = base .. "notes", Method = "GET", Headers = key })
if answer then
    print(answer.StatusCode .. " " .. answer.StatusMessage .. ", Success " .. tostring(answer.Success) .. ", " .. answer.Headers["content-type"])
end
local refused = HttpService:FetchAsync({ Url = base .. "notes" })
if refused then
    print(refused.StatusCode .. ": " .. refused.Body)
end

local missing, why = HttpService:GetTextAsync(base .. "nowhere")
print(tostring(missing) .. ", " .. tostring(why))
page:Destroy()
TimeService:WaitAsync(0.2)
local gone, goneWhy = HttpService:GetTextAsync(base .. "notes", key)
print(tostring(gone) .. ", " .. tostring(goneWhy))

prints (print puts a space between values)

before: {}
saved note 1 nil
saved note 2 nil
after: ["rain at noon","wind from the west"]
200 OK, Success true, application/json
401: this route needs the page's key (X-Shot-Key, or ?k= from page.Url)
nil, GetTextAsync <page>/nowhere: status 404: no page /nowhere
nil, GetTextAsync <page>/notes: No connection could be made because the target machine actively refused it. (os error 10061)

The empty list of notes came out as {} (see JSON).

The run itself

RunService: the run's arguments, the project's folder, the environment, and ending the run.

RunService.Arguments : {string}             read only: what came after `--` on the shot run line
RunService.ProjectFolder : string           read only: the folder that holds the project file, with / slashes
RunService.Stopping : signal ()             the run is ending
RunService:GetEnv(name : string) -> string?   an environment variable, or nil
RunService:Quit(code : number?)             ends the run with that exit code (0 when nil); never returns
  • shot run Demo.ssp -- north "b c" gives two arguments, the second with its space.
  • Quit runs every Stopping callback first (up to 3 seconds in all), then stops everything the run started. Code after Quit never runs.

Example, run from the folder above the project's folder weather, as shot run weather/Demo.ssp -- north "b c":

use RunService

print(#RunService.Arguments .. " arguments: " .. table.concat(RunService.Arguments, "|"))
print("the project's folder is called " .. (string.match(RunService.ProjectFolder, "[^/]+$") or ""))
print("PATH is set: " .. tostring(RunService:GetEnv("PATH") ~= nil))
print("NO_SUCH_NAME: " .. tostring(RunService:GetEnv("NO_SUCH_NAME")))

RunService.Stopping:Connect(function()
    print("stopping")
end)
RunService:Quit(2)
print("never printed")

prints, and the run ends with exit code 2:

2 arguments: north|b c
the project's folder is called weather
PATH is set: true
NO_SUCH_NAME: nil
stopping

Not there yet

shot check knows only the nine services above. A require of any other name is an Unknown require, which lists what there is:

Demo.ssp/Main.shot:1:14   Unknown require "@shotsystem/NopeService": a System project's services are EncodingService, FileService, HttpService, JsonService, PageService, ProcessService, ProgramService, RunService and TimeService, and its built-in modules Mop, QuickSignal and QuickValue (use FileService)

See also

OverviewSystem projects
One-file projectsthe header and project kinds
Externalsuse external
Instancesinstances, signals, Instance.new, Clone and Destroy
FileServicefiles and folders
ProcessServiceprograms on this computer