13. Putting it together.
tasks is now two packages: the taskcore library and a program with a clean execute at its center. In this last chapter you put the board on the network. tasks serve starts a small HTTP service, and every request runs the same execute the command line uses. Only one thing changes per request: where the output goes.
The service speaks plain HTTP on 127.0.0.1. The standard library has TLS for clients, but not yet for servers, so there is no HTTPS here. More on that near the end.
The plan: a request is a command#
You could write a second set of handlers for HTTP. You don't need to. Everything the program does already goes through one function:
pub effect fn execute<'text>(words: &'text Words) -> i32
! LogError | UsageError | UnknownCommand<'text> | DuplicateTask | NoSuchTask | CorruptFile
| FileError | WriterError | OutOfMemoryError | TaskIdExhaustedError | StalledError
? &mut Logger | &mut FileSystem | &mut SystemClock | &mut MonotonicClock | &mut Allocator {It takes Words, uses services for everything outside itself, and fails with typed errors. That gives a simple plan for each request:
Route. Turn the method, path and body into
Words, as if they had been typed on the command line.Answer. Run
executewith a Logger that writes into the response body instead of stdout.Status. Pick the HTTP status from the typed failure, if there was one.
Respond. Send the status line, headers and body over the socket.
Steps 1 to 3 never touch a socket, so you can test them like any other function. The new module is tasks/src/Http.silk. As in chapter 12, run the commands from tasks/ inside the workspace; the program builds into the shared ../build folder. These are its routes:
| Request | Command |
|---|---|
GET /tasks | list |
GET /tasks.csv | export |
GET /version | version |
POST /tasks, body is the title | add <title> |
POST /tasks?priority=high | add <title> high |
POST /tasks/<id>/done | done <id> |
A Logger that writes the response#
In chapter 8 you replaced the file system and the clock in tests by providing different services. The same trick works here. The command line provides StdoutLogger. The server provides a Logger that collects text in memory:
pub struct BodyLogger { pub text: TextWriter }
impl BodyLogger {
pub fn make() -> BodyLogger {
return BodyLogger { text: TextWriter.make() }
}
effect fn write<Args>(
self: &mut Self,
level: LogLevel,
static template: string<'static>,
args: &Args
) -> () ! LogError {
let written = run Effect.result(Format.format<Args>(template, args) |> Effect.provideMut<Writer>(&mut self.text))
return match move written {
Result<(), WriterError>.Success { .. } => ()
Result<(), WriterError>.Failure { .. } => run logFailed()
}
}
}
effect fn logFailed() -> never ! LogError {
fail Logger.failure(1)
}
impl Logger for BodyLogger {
log: BodyLogger.write
}TextWriter is the in-memory Writer from chapter 9. It now lives in taskcore.Store, and it was already pub, so Http.silk imports it. write formats the template into it, exactly as Effect.log would format onto stdout. The template is a static parameter, just like bar's width in chapter 11: each template gets its own compiled copy.
That is the whole adapter. list, add and the rest have no idea they are serving a web request.
Routing#
route compares bytes and builds Words. It returns None for a request it does not know:
// Which command a request means, or None for an unknown route.
pub effect fn route(method: &[u8], target: &[u8], body: &[u8]) -> Option<Words>
! OutOfMemoryError
? &mut Allocator {
if equal(method, b"GET") {
if equal(target, b"/tasks") {
return run words(b"list", b"", b"")
}
if equal(target, b"/tasks.csv") {
return run words(b"export", b"", b"")
}
if equal(target, b"/version") {
return run words(b"version", b"", b"")
}
}
if equal(method, b"POST") {
if equal(target, b"/tasks") {
return run words(b"add", body, b"")
}
if startsWith(target, b"/tasks?priority=") {
return run words(b"add", body, rest(target, 16))
}
// /tasks/<id>/done
if startsWith(target, b"/tasks/") && target.length > 12 && equal(rest(target, target.length - 5), b"/done") {
return run words(b"done", Slice.view(target, 7, target.length - 12), b"")
}
}
return Option<Words>.None
}equal, startsWith and rest are small private helpers over byte slices, and words copies three slices into a Words. Notice what route does not do: it does not check that the priority is a real one, or that the id is a number. parseCommand already does that, and its UsageError will become a 400 below.
Answering, and choosing the status#
answer runs one command with a fresh BodyLogger, then looks at the outcome:
// Runs one command with the response body as its Logger, and picks the HTTP status from
// the typed failure, if there was one.
pub effect fn answer(words: &Words) -> Reply
! OutOfMemoryError
? &mut FileSystem | &mut SystemClock | &mut MonotonicClock | &mut Allocator {
let mut logger = BodyLogger.make()
let outcome = run Effect.result(execute(words)) |> Effect.provideMut<Logger>(&mut logger)
let mut status: u16 = 200
if let Result.Failure { error } = move outcome {
status = match &error {
UsageError { .. } => 400
UnknownCommand { .. } => 400
NoSuchTask { .. } => 404
DuplicateTask { .. } => 409
_ => 500
}
// The same messages the command line prints.
drop run Effect.result(explain(move error)) |> Effect.provideMut<Logger>(&mut logger)
}
return Reply { status: status, body: move logger.text.out }
}Look at the requirement row. answer needs a FileSystem, two clocks and an Allocator, but no Logger. It provides the Logger itself, so ? no longer lists it. A provider removes a requirement; that is what provideMut has been doing since chapter 1.
This is where typed failures pay off. Because execute lists every way it can fail, match &error can map each one to a status. match &error looks at the failure without moving it. Then explain, the same handler main uses, writes the familiar message into the body. The CLI and the server can never disagree about what went wrong.
Http.silk imports execute and explain from main, and main imports Http. That is another import cycle, as in chapter 12, and it is fine.
Testing without sockets#
Since route and answer are ordinary effect functions, two new tests in tests.silk cover them. Here is the one for conflicts. It runs add Buy milk twice against the in-memory fakes from chapter 8:
test effect fn duplicatesAreConflicts() ! AssertionError<'static> | OutOfMemoryError {
let mut allocator = Allocator.systemAllocatorProvider()
let mut files = MemoryFiles { contents: Bytes.make(), present: false, allocator: Allocator.systemAllocatorProvider() }
let mut clock = FixedClock { seconds: 1000 }
let mut timer = VirtualClock { mark: SystemClock.make(0, 0) }
let add = run wordsOf(b"add", b"Buy milk", b"") |> Effect.provideMut<Allocator>(&mut allocator)
let first = run answer(&add)
|> Effect.provideMut<FileSystem>(&mut files)
|> Effect.provideMut<SystemClock>(&mut clock)
|> Effect.provideMut<MonotonicClock>(&mut timer)
|> Effect.provideMut<Allocator>(&mut allocator)
let second = run answer(&add)
|> Effect.provideMut<FileSystem>(&mut files)
|> Effect.provideMut<SystemClock>(&mut clock)
|> Effect.provideMut<MonotonicClock>(&mut timer)
|> Effect.provideMut<Allocator>(&mut allocator)
run Testing.expect(first.status == 200, "the first add succeeds")
run Testing.expect(sameBytes(Bytes.asSlice(&first.body), b"added #1\n"), "and says so")
run Testing.expect(second.status == 409, "the second is a conflict")
run Testing.expect(sameBytes(Bytes.asSlice(&second.body), b"that task already exists as #1\n"), "with the CLI's message")
}The other, doneRouteCarriesTheId, checks that POST /tasks/12/done routes to done 12 and that DELETE /tasks routes nowhere. Add import Http { answer, route } and import silk.core.option { Option } to tests.silk, then run everything:
silk test tests::doneRouteCarriesTheId
PASS 28 us
tests::duplicatesAreConflicts
PASS 141 us
tests::finishingRecordsTheClockTime
PASS 141 us
tests::slowRemindersAreCancelled
PASS 309 us
tests::unknownTaskIsReported
PASS 53 us
Tests 5 passed, 0 failed (0 cached, 5 executed, 5 selected, 5 discovered)
Time 2 msThe HTTP logic is tested before a single port is opened.
The socket side#
Now the part that does touch the network. It uses four standard library modules:
silk/net/socket_native:listenopens a listening socket, andwithListenerruns an effect with it.silk/http/server_native:serveNextaccepts one connection and hands it to your handler.silk/http/server: theConnectionHandlerinterface,withRequest,readSomeandrespond.silk/http/message,silk/http/head,silk/http/headersandsilk/http/body: headers, status codes and limits.
Several of these modules each export a type called Limits, so the imports rename them with as:
import silk.http.body { Limits as BodyLimits, TrailerPolicy }
import silk.http.head { Limits as HeadLimits }
import silk.http.headers { Headers, Limits as ValueLimits }
import silk.http.server { BodyMode, Connection as HttpConnection, ConnectionHandler, Limits as HttpLimits, Request, ResponseWriter, ReusePolicy, ServerError, readSome, respond, withRequest }Limits#
An HTTP server reads bytes from strangers. The standard library makes you say, up front, how much it may read and keep: the longest request line, the most headers, the largest body, and so on. limits() fills in one HttpLimits value. Here is its first part:
fn limits() -> HttpLimits {
return HttpLimits {
head: HeadLimits {
maxHeadBytes: 8192,
maxStartLineBytes: 1024,
maxFieldLineBytes: 2048,
maxOwnedBytes: 65536,
values: valueLimits(),
},The body limits allow 1 KiB of payload, which is plenty for a task title. Two other settings shape the service: maxRequestsPerConnection: 1, and, below, ReusePolicy.CloseAfterResponse. Each connection carries one request and is then closed. See the silk/http/server page of the std lib API reference for every field.
The limits are only checked when a connection arrives, not at build time. If you set head.maxOwnedBytes to 8192, too small for 8192 bytes of head plus the header records, everything builds. Then each request fails:
silk build >/dev/null 2>&1; (../build/llvm/x86_64-unknown-linux-gnu/debug/tasks serve 18414 1 & sleep 0.5; curl -s -i http://127.0.0.1:18414/tasks; echo "[curl exit $?]"; wait)serving http://127.0.0.1:18414/tasks
a connection failed
[curl exit 56]The server logs "a connection failed", and curl's exit status 56 means the connection broke while it was receiving. If you change the limits and a well-formed request starts failing like this, suspect the limits first.
One request#
serveRequest is the heart of the socket side. It copies what it needs out of the request, reads the body, and then uses route and answer:
// Copy what we need from the head: reading the body needs the request back.
let head = run Request.head(&request.*)
let method = run Bytes.copy(String.utf8Bytes(head.method().format()))
let target = run Bytes.copy(String.utf8Bytes(RequestTarget.format(head.target())))
let body = run readBody(&mut request.*)
let found = run route(Bytes.asSlice(&method), Bytes.asSlice(&target), Bytes.asSlice(&body))
let reply = match move found {
Option.Some { value } => run answer(&value)
Option.None => run notFound()
}
let status = reply.status
run Effect.catchAll(logRequest(asText(Bytes.asSlice(&method)), asText(Bytes.asSlice(&target)), status), ignoreLogError)The copies are there because of borrowing. head borrows the request, and readBody needs &mut request.*. Copying the method and target into owned Bytes ends the shared loan before the mutable one starts. readBody calls readSome in a loop until the body is complete.
logRequest writes one line per request to the server's own Logger. That is the StdoutLogger from main, not the BodyLogger, which belongs to answer. Two Loggers are in play, and each call site gets the nearest provider.
Headers in one array#
The response needs a Content-Length and a Content-Type. Each Header borrows its value, so the type is Header<'value>:
effect fn header<'value>(name: string<'static>, value: &'value [u8]) -> Header<'value> ! ServerError {The Content-Type value is a 'static byte string. The Content-Length value is borrowed from a local Bytes holding the digits. Putting the two in one array is natural. Here is the main of a small standalone program that does just that, with a plain version of header above it:
pub fn main() -> i32 {
let length: [u8; 2] = ['4', '2']
// A 'static header and a header borrowing `length`, side by side in one array.
let entries = [
header("Content-Type", b"text/plain"),
header("Content-Length", &length),
]
return 0
}silk checkChecking tasks (x86_64-unknown-linux-gnu)
load 145 modules, 1 in package 0.07s
check 2 modules 0.10s
borrow 92 bodies 0.00s
Finished tasks in 0.18sThe two elements look alike, but their types differ in the lifetime. The first is a Header<'static>. The second borrows length, so it can only live as long as length does. An array has one element type, and the compiler settles on the shorter of the two lifetimes. A 'static header is fine wherever a shorter-lived one is expected, so both fit, in either order.
header in Http.silk can fail, so each call needs its own run. Binding the headers first keeps the array short:
let length = run Effect.catchAll(decimal(reply.body.length()), badLength)
let contentLength = run header("Content-Length", Bytes.asSlice(&length))
let contentType = run header("Content-Type", b"text/plain; charset=utf-8")
let entries = [contentLength, contentType]Don't leave Content-Length out. The response uses BodyMode.ContentLength, which promises that header. Without it, the request is logged with 200 and then the write fails:
serving http://127.0.0.1:18414/tasks
GET /tasks -> 200
a connection failed
[curl exit 52]curl exit 52 means "empty reply from server".
Writing the body: a callback#
respond sends the status line and headers, then calls you back to write the body. Your callback receives a ResponseWriter borrow that is only valid during that call. In Silk terms the callback is higher-ranked: it must work for whatever lifetime 'call the caller picks. See Closures and callable types in the reference.
The natural first try is a closure, passed as the last argument of respond, that captures reply.body:
effect fn(writer: &mut ResponseWriter<Connection>) -> u16 ! ServerError ? &mut MonotonicClock {
drop run ResponseWriter.writeAll(&mut writer.*, Bytes.asSlice(&reply.body), Option<Instant>.none())
let none: [Header<'static>; 0] = []
let trailers = match move Headers.make(&none, valueLimits()) {
Result.Failure { error } => { fail ServerError.Value { error: move error } }
Result.Success { value } => value
}
run ResponseWriter.finish(&mut writer.*, &trailers, Option<Instant>.none())
return 0
},Checking tasks (x86_64-unknown-linux-gnu)
load 235 modules, 8 in package 0.14s
check 13 modules 1.90s
borrow 891 bodies 0.07s
Finished tasks in 2.06sThat works. The closure borrows reply.body, and the borrow is fine because respond is done with the callback before it returns. But ten lines of body writing now sit in the middle of the respond call. This chapter moves them into a named function plus a staged call. Write the body writer as a function whose last parameter is the body:
// Writes the body once the head is out. The extra `body` argument is bound at the call site:
// `writeBody(move body)` is a function of the writer alone.
effect<'call> fn writeBody<'call, 'writerView: 'call, 'requestView: 'writerView, 'connectionView: 'requestView, 'transportView: 'connectionView>(
writer: &'call mut ResponseWriter<'writerView, 'requestView, 'connectionView, 'transportView, Connection>,
body: Bytes,
) -> u16 ! ServerError ? &mut MonotonicClock {
drop run ResponseWriter.writeAll(&mut writer.*, Bytes.asSlice(&body), Option<Instant>.none())
let none: [Header<'static>; 0] = []
let trailers = match Headers.make(&none, valueLimits()) {
Result.Failure { error } => { fail ServerError.Value { error: move error } }
Result.Success { value } => value
}
run ResponseWriter.finish(&mut writer.*, &trailers, Option<Instant>.none())
return 0
}The lifetime list is long, but it only says that the writer borrows the request, which borrows the connection, which borrows the transport. Calling a function with fewer arguments than parameters fills the trailing ones. So writeBody(move reply.body) binds body and leaves a function of the writer alone, which is what respond wants:
drop run respond(
move request,
&response,
BodyMode.ContentLength { length: usize.toU64(reply.body.length()) },
ReusePolicy.CloseAfterResponse,
TrailerPolicy.defaultPolicy(),
Option<Instant>.none(),
writeBody(move reply.body),
)
return statusYou have used staged calls before without the name: acceptLoop(count) below is one too.
The handler and the accept loop#
serveNext wants a value that implements ConnectionHandler. TasksHandler holds nothing; its handle method asks for one request and passes it to serveRequest:
drop handler
let handled = run withRequest(&mut connection.*, Option<Instant>.none(), serveRequest)
return match move handled {
Option.None => 0
Option.Some { value } => value
}The accept loop serves count connections, or forever when count is 0. A failed connection is logged and the loop carries on, so one bad client cannot stop the server:
// Serves `count` connections (0: forever), one at a time. A broken connection is logged and skipped.
effect fn acceptLoop(listener: &mut Listener, count: u32) -> i32
! LogError | OutOfMemoryError
? &mut Logger | &mut FileSystem | &mut SystemClock | &mut MonotonicClock | &mut Allocator {
let mut served: u32 = 0
while count == 0 || served < count {
let outcome = run Effect.result(serveNext<u16, ServerError | OutOfMemoryError>(
&mut listener.*, limits(), Option<Instant>.none(), TasksHandler {},
))
if let Result.Failure { error } = move outcome {
drop error
run Effect.log("a connection failed\n", &())
}
served = served + 1
}
return 0
}Finally, serve opens the socket:
pub effect fn serve(port: u16, count: u32) -> i32
! LogError | OutOfMemoryError
? &mut Logger | &mut FileSystem | &mut SystemClock | &mut MonotonicClock | &mut Allocator {
let endpoint = Endpoint.make(IpAddress.V4 { value: Ipv4Address.fromOctets([127, 0, 0, 1]) }, Port.fromU16(port))
let options = match ListenOptions.make(128, true, 1000000) {
Result.Success { value } => value
Result.Failure { .. } => ListenOptions.defaults()
}
let listening = run Effect.result(listen(endpoint, options))
let listener = match move listening {
Result.Success { value } => move value
Result.Failure { error } => {
drop error
run Effect.log("cannot listen on 127.0.0.1:{port}\n", &.{ port: port })
return 1
}
}
run Effect.log("serving http://127.0.0.1:{port}/tasks\n", &.{ port: port })
return run withListener(move listener, acceptLoop(count))
}ListenOptions.make(128, true, 1000000) sets a backlog of 128, turns on address reuse and polls every millisecond. The defaults leave address reuse off. Then restarting the server right after stopping it fails for a while, because the old socket still holds the port.
The server binds to 127.0.0.1 only, so nothing outside your machine can reach it.
Wiring serve into main#
serve is not a Command. execute runs once per request, and a request should never start another server. So main checks for serve before it calls execute, and runs the server under the same real providers:
if sameText(Bytes.asSlice(&words.command), "serve") {
let serving = Effect.catchAll(startServer(&words), serverFailed)
return run move serving
|> Effect.provideMut<Logger>(&mut logger)
|> Effect.provideMut<FileSystem>(&mut files)
|> Effect.provideMut<SystemClock>(&mut clock)
|> Effect.provideMut<MonotonicClock>(&mut timer)
|> Effect.provideMut<Allocator>(&mut allocator)
}startServer parses the optional port (default 8080) and connection count, then calls Http.serve:
// tasks serve [port] [connections]: the same board over HTTP. 0 connections means forever.
effect fn startServer<'text>(words: &'text Words) -> i32
! LogError | UsageError | OutOfMemoryError
? &mut Logger | &mut FileSystem | &mut SystemClock | &mut MonotonicClock | &mut Allocator {
let port = run portOf(run text(&words.first))
let count = run countOf(run text(&words.second))
return run Http.serve(port, count)
}main.silk has import Http with no braces, so it calls the function by its module name, Http.serve. serverFailed turns a UsageError into the usage text, which now mentions serve:
../build/llvm/x86_64-unknown-linux-gnu/debug/tasks serve abcusage: tasks list | add <title> [low|normal|high] | done <id> | remind [ms] | export | version | serve [port] [connections]Try it#
Build, then start the server in the background on port 18413. Any free port works. The 12 makes it stop after twelve connections, which suits a scripted session; leave it out to serve forever.
silk buildCompiling tasks (x86_64-unknown-linux-gnu, debug)
load 235 modules, 8 in package 0.12s
check 12 modules 1.48s
codegen 7,945 functions 2.83s
borrow 1,452 bodies 0.11s
link clang 1.07s
Finished /home/you/tasks-workspace/build/llvm/x86_64-unknown-linux-gnu/debug/tasks in 5.54s../build/llvm/x86_64-unknown-linux-gnu/debug/tasks serve 18413 12 &Start with an empty board. -i shows the response head:
curl -s -i http://127.0.0.1:18413/tasksHTTP/1.1 200 OK
Content-Length: 30
Content-Type: text/plain; charset=utf-8
Connection: close
0 open [--------------------]The body is exactly what tasks list prints. Add some tasks. The title is the request body:
curl -s -i -X POST -d 'Buy milk' http://127.0.0.1:18413/tasksHTTP/1.1 200 OK
Content-Length: 9
Content-Type: text/plain; charset=utf-8
Connection: close
added #1curl -s -X POST -d 'Write the Silk book' 'http://127.0.0.1:18413/tasks?priority=high'
curl -s -X POST -d 'Water the plants' 'http://127.0.0.1:18413/tasks?priority=low'added #2
added #3Now the failures. A duplicate title, with extra spaces, is a 409:
curl -s -i -X POST -d ' Buy milk ' http://127.0.0.1:18413/tasksHTTP/1.1 409 Conflict
Content-Length: 31
Content-Type: text/plain; charset=utf-8
Connection: close
that task already exists as #1A priority that parseCommand rejects is a 400, with the usage text:
curl -s -i -X POST -d 'Call the plumber' 'http://127.0.0.1:18413/tasks?priority=urgent'HTTP/1.1 400 Bad Request
Content-Length: 124
Content-Type: text/plain; charset=utf-8
Connection: close
usage: tasks list | add <title> [low|normal|high] | done <id> | remind [ms] | export | version | serve [port] [connections]Finish a task, then try one that does not exist:
curl -s -X POST http://127.0.0.1:18413/tasks/1/donefinished #1curl -s -i -X POST http://127.0.0.1:18413/tasks/9/doneHTTP/1.1 404 Not Found
Content-Length: 20
Content-Type: text/plain; charset=utf-8
Connection: close
there is no task #9The list, the CSV export and the version all work too:
curl -s http://127.0.0.1:18413/tasks[ ] #2 Write the Silk book (high)
[ ] #3 Water the plants (low)
[x] #1 Buy milk (normal, done today)
2 open [######--------------]curl -s http://127.0.0.1:18413/tasks.csvid,title,priority,done
1,Buy milk,normal,yes
2,Write the Silk book,high,no
3,Water the plants,low,nocurl -s -i http://127.0.0.1:18413/nopeHTTP/1.1 404 Not Found
Content-Length: 14
Content-Type: text/plain; charset=utf-8
Connection: close
no such routecurl -s http://127.0.0.1:18413/versiontasks 0.13 (linux, debug build)
tasks file /tasks.txt, reminders wait 300msThat was the twelfth connection, so the server exits with status 0. Meanwhile, its own output was one line per request:
serving http://127.0.0.1:18413/tasks
GET /tasks -> 200
POST /tasks -> 200
POST /tasks?priority=high -> 200
POST /tasks?priority=low -> 200
POST /tasks -> 409
POST /tasks?priority=urgent -> 400
POST /tasks/1/done -> 200
POST /tasks/9/done -> 404
GET /tasks -> 200
GET /tasks.csv -> 200
GET /nope -> 404
GET /version -> 200The server saved to the same tasks.txt as the command line, so the CLI sees the same board:
../build/llvm/x86_64-unknown-linux-gnu/debug/tasks list[ ] #2 Write the Silk book (high)
[ ] #3 Water the plants (low)
[x] #1 Buy milk (normal, done today)
2 open [######--------------]If the port is already in use, here by another running server, serve says so and exits with status 1:
../build/llvm/x86_64-unknown-linux-gnu/debug/tasks serve 18413 1cannot listen on 127.0.0.1:18413What this server is, and is not#
The server handles one connection at a time. Requests run in order, each one loads tasks.txt in full, and a slow client makes the next one wait. That keeps the board consistent with no locking at all, and it is plenty for a personal tool.
It also speaks plain HTTP. The standard library's TLS support is client-side only (silk/tls/client and silk/tls/connection), so a Silk program can call an HTTPS service but cannot yet serve one. Keep the server on 127.0.0.1. If it must be reachable from elsewhere, put a TLS-terminating proxy in front of it.
Summary#
You can reuse a whole program as a service by changing a provider:
BodyLoggerturns CLI output into a response body.Typed failures map directly to HTTP statuses, and the same
explainwrites the message for both front ends.Keeping routing and answering free of sockets lets you test them with the fakes you already have.
silk/net/socket_nativeandsilk/http/servergive youlisten,serveNext,withRequest,readSomeandrespond, with explicit limits checked per connection.A staged call such as
writeBody(move reply.body)builds a callback from a named function, which keeps a long callback out of the call that receives it.The elements of one array literal may borrow for different lifetimes. The array gets the shortest of them, whatever the order.
Where to go next#
That is the end of the book. In thirteen chapters tasks grew from one log line to two packages, a test suite, reminders on fibers, a compile-time CSV export and an HTTP service. Along the way you met nearly every part of the language at least once.
From here, three places will serve you best:
The Language reference. Every page this book linked, from Lexical structure to Package parameters, gives the full rules and the diagnostic codes. When the compiler prints a code like SEM0021, the reference page for that topic explains the rule behind it.
The std lib API reference. One page per module, generated from the source. Start with the modules you used here:
silk/core/effect,silk/io/logger,silk/system/filesystem,silk/collections/hash_map,silk/collections/list,silk/concurrency/local_schedulerandsilk/http/server.The guides. Shorter, task-focused pages for things this book only touched on, or left out.
A good next project is to extend tasks yourself. Add a DELETE /tasks/<id> route and a remove command, give tasks due dates, or write a second program that depends on taskcore and prints a weekly summary. Each one exercises the same pieces: a new typed failure, a new Command variant, a test with fake providers, and a build that tells you exactly what you missed.