What Ruby-on-ICP can do, run live against the backend canister. Most panels are
a ruby_wasm_builder challenge: the
Ruby it runs, the result the builder expects, and a button that calls the canister
and compares. Panels marked showcase are small apps written for this
demo that use the same capability, and panels marked web are the
canister answering the IC’s HTTP gateway itself, as a browser would see it.
Ruby runtime
Float to String since 0.1.0
`0.1 + 0.2` printed as its shortest round-trip form, the same digits CRuby gives you everywhere. CRuby computes this with its own dtoa rather than the C library, so it holds on wasm too.
Ruby source
# Float to String
#
# `0.1 + 0.2` printed as its shortest round-trip form, the same digits CRuby
# gives you everywhere. CRuby computes this with its own dtoa rather than the C
# library, so it holds on wasm too.
#
# Ported from ruby_wasm_builder challenges/float (introduced 0.1.0, ported at 0.6.0).
def float_main
(0.1 + 0.2).to_s
end
Expected: 0.30000000000000004
External enumerators since 0.1.0
`Enumerator#next` runs the iteration in a Fiber and switches back and forth with it. Fibers switch stacks, which on wasm means unwinding and rewinding the call stack, and this shows that it works inside a canister.
Ruby source
# External enumerators
#
# `Enumerator#next` runs the iteration in a Fiber and switches back and forth
# with it. Fibers switch stacks, which on wasm means unwinding and rewinding the
# call stack, and this shows that it works inside a canister.
#
# Ported from ruby_wasm_builder challenges/enumerator (introduced 0.1.0, ported at 0.6.0).
def enumerator_main
e = [10, 20, 30].each
[e.next, e.next, e.next].sum.to_s
end
Expected: 60
Raise and rescue since 0.1.0
A `raise` caught by an ordinary Ruby `rescue`. Exceptions unwind through the wasm call stack and land in the handler, just as on a desktop Ruby.
Ruby source
# Raise and rescue
#
# A `raise` caught by an ordinary Ruby `rescue`. Exceptions unwind through the
# wasm call stack and land in the handler, just as on a desktop Ruby.
#
# Ported from ruby_wasm_builder challenges/raise (introduced 0.1.0, ported at 0.6.0).
module RaisePort
class ChallengeError < StandardError; end
end
def raise_main
raise RaisePort::ChallengeError, "raised"
rescue RaisePort::ChallengeError => e
"rescued #{e.class.name.split('::').last}: #{e.message}"
end
Expected: rescued ChallengeError: raised
Deep recursion since 0.2.0
Three hundred frames deep, each one through a C method (`map` calls the block from C), so every level is a C frame as well as a Ruby one. 300 is a floor that holds with room to spare: past roughly 650 levels a call traps rather than raising SystemStackError, and the IC rolls the call back.
Ruby source
# Deep recursion
#
# Three hundred frames deep, each one through a C method (`map` calls the block
# from C), so every level is a C frame as well as a Ruby one. 300 is a floor
# that holds with room to spare: past roughly 650 levels a call traps rather
# than raising SystemStackError, and the IC rolls the call back.
#
# Ported from ruby_wasm_builder challenges/recursion (introduced 0.2.0, ported at 0.6.0).
def recursion_depth(n) = n.zero? ? 0 : [n - 1].map { |m| recursion_depth(m) }.first + 1
def recursion_main
recursion_depth(300).to_s
end
Expected: 300
Memory
Garbage collection since 0.1.0
An allocation heavy enough to force a collection, with live objects that must survive it intact. A broken collector would free or move a live object, and it would then no longer read back correctly.
Ruby source
# Garbage collection
#
# An allocation heavy enough to force a collection, with live objects that must
# survive it intact. A broken collector would free or move a live object, and
# it would then no longer read back correctly.
#
# Ported from ruby_wasm_builder challenges/gc (introduced 0.1.0, ported at 0.6.0).
def gc_main
before = GC.count
kept = Array.new(1_000) { |i| "kept-#{i}" }
200_000.times { |i| "garbage-#{i}" }
intact = kept.each_with_index.all? { |s, i| s == "kept-#{i}" }
"collected: #{GC.count > before}, survivors intact: #{intact}"
end
Expected: collected: true, survivors intact: true
Objects that clean themselves up showcase, since 0.1.0
One call builds a 50,000-string object five times in a row, dropping each one before building the next, and reports the live-object count after every round. If nothing were reclaimed the count would climb by about 50,000 each round; it stays flat instead. Nothing here frees memory or asks for a collection: the Ruby runtime collects on its own. (A Rust canister frees an object when its owner goes out of scope, by rule. Ruby does it by collecting.)
What did not work: showing a single object appear and then vanish. On this build the most recent large object is still counted after it is dropped, even after more collections, a forced GC, a deep call to scrub the stack, or a separate call. Earlier objects are reclaimed, which is why the rounds are flat. What holds the last one is not identified. The counts are also approximate, since they include whatever else the VM holds.
Ruby source
# Objects that clean themselves up
#
# One call builds a 50,000-string object five times in a row, dropping each one
# before building the next, and reports the live-object count after every round.
# If nothing were reclaimed the count would climb by about 50,000 each round; it
# stays flat instead. Nothing here frees memory or asks for a collection: the
# Ruby runtime collects on its own. (A Rust canister frees an object when its
# owner goes out of scope, by rule. Ruby does it by collecting.)
#
# What did not work: showing a single object appear and then vanish. On this
# build the most recent large object is still counted after it is dropped, even
# after more collections, a forced GC, a deep call to scrub the stack, or a
# separate call. Earlier objects are reclaimed, which is why the rounds are flat.
# What holds the last one is not identified. The counts are also approximate,
# since they include whatever else the VM holds.
#
# Showcase for ruby_wasm_builder challenges/gc_state (introduced 0.1.0, written for 0.6.0).
GC_LIFECYCLE_ROUNDS = 5
def gc_lifecycle_live = GC.stat(:heap_live_slots)
# Built inside its own method so nothing in the caller refers to it afterwards.
def gc_lifecycle_build = Array.new(50_000) { |i| "object-#{i}" }.size
def gc_lifecycle
before = gc_lifecycle_live
collections = GC.count
rounds = Array.new(GC_LIFECYCLE_ROUNDS) do
gc_lifecycle_build
gc_lifecycle_live
end
"live objects: #{before} before; after each round: #{rounds.join(', ')}; " \
"the runtime ran #{GC.count - collections} collection(s) by itself"
end
Objects kept across messages and collections since 0.1.0
Each call adds a thousand objects to a global and forces a collection, and every object from the earlier calls must still read back intact. A call is a fresh entry into the VM, so this checks the collector still finds what previous messages left behind.
Ruby source
# Objects kept across messages and collections
#
# Each call adds a thousand objects to a global and forces a collection, and
# every object from the earlier calls must still read back intact. A call is a
# fresh entry into the VM, so this checks the collector still finds what
# previous messages left behind.
# Stateful: yes
#
# Ported from ruby_wasm_builder challenges/gc_state (introduced 0.1.0, ported at 0.6.0).
$gc_state_kept = []
def gc_state_main
1_000.times { $gc_state_kept << "kept-#{$gc_state_kept.size}" }
GC.start
intact = $gc_state_kept.each_with_index.all? { |s, i| s == "kept-#{i}" }
"#{$gc_state_kept.size} kept, intact: #{intact}"
end
On a fresh canister:
1000 kept, intact: true
2000 kept, intact: true
3000 kept, intact: true
Standard library and files
The standard library since 0.2.0
`Set` is loaded from the Ruby standard library on first use. The base module ships the standard library, so a canister can `require` it without carrying its own copy.
Ruby source
# The standard library
#
# `Set` is loaded from the Ruby standard library on first use. The base module
# ships the standard library, so a canister can `require` it without carrying
# its own copy.
#
# Ported from ruby_wasm_builder challenges/stdlib (introduced 0.2.0, ported at 0.6.0).
def stdlib_main
Set.new([1, 2, 2, 3]).size.to_s
end
Expected: 3
An application of more than one file since 0.1.0
One Ruby file loads a second one beside it with `require_relative`. A canister is a directory of Ruby, not a single script.
Ruby source
# An application of more than one file
#
# One Ruby file loads a second one beside it with `require_relative`. A canister
# is a directory of Ruby, not a single script.
#
# Ported from ruby_wasm_builder challenges/require_relative (introduced 0.1.0, ported at 0.6.0).
require_relative "require_relative/greeting"
def require_relative_main
Greeting.hello
end
Expected: hello from greeting.rb
Files read as text since 0.9.0
A file read with no encoding is text: `Encoding.default_external` is UTF-8, the locale's, as `ruby` sets it under a UTF-8 locale, and so are the `locale` and `filesystem` pseudo-encodings. `write` stores a line with accents, an em dash and kanji as raw bytes; `read` gets it back as UTF-8 text, so its length counts 25 characters (not 32 bytes), it equals the literal, and a UTF-8 Regexp finds "José" at character 9. Reading the same file as bytes is still ASCII-8BIT, as a Candid blob is. Up to 0.8.0 it was ASCII-8BIT: `env` raised and `read` failed with Encoding::CompatibilityError. The setting is made at every boot, so the command below shows it again after an upgrade.
Ruby source
# Files read as text
#
# A file read with no encoding is text: `Encoding.default_external` is UTF-8,
# the locale's, as `ruby` sets it under a UTF-8 locale, and so are the
# `locale` and `filesystem` pseudo-encodings. `write` stores a line with
# accents, an em dash and kanji as raw bytes; `read` gets it back as UTF-8 text,
# so its length counts 25 characters (not 32 bytes), it equals the literal, and
# a UTF-8 Regexp finds "José" at character 9. Reading the same file as bytes is
# still ASCII-8BIT, as a Candid blob is. Up to 0.8.0 it was ASCII-8BIT: `env`
# raised and `read` failed with Encoding::CompatibilityError. The setting is
# made at every boot, so the command below shows it again after an upgrade.
# Methods: env, write, read, binread
# Cli: yes
#
# Ported from ruby_wasm_builder challenges/default_external (introduced 0.9.0, ported at 0.10.0).
DEFAULT_EXTERNAL_TEXT = "Aklatan: José Rizal — 日本\n"
DEFAULT_EXTERNAL_PATH = File.join(IC::DATA_DIR, "default_external_aklatan.txt")
def default_external_env
"external=#{Encoding.default_external.name} internal=#{Encoding.default_internal&.name || 'nil'} " \
"locale=#{Encoding.find('locale').name} filesystem=#{Encoding.find('filesystem').name}"
end
def default_external_write
File.binwrite(DEFAULT_EXTERNAL_PATH, DEFAULT_EXTERNAL_TEXT.b)
"wrote #{File.size(DEFAULT_EXTERNAL_PATH)} bytes"
end
def default_external_read
s = File.read(DEFAULT_EXTERNAL_PATH)
"#{s.encoding.name} #{s.length} #{s == DEFAULT_EXTERNAL_TEXT} #{s =~ /José/}"
end
def default_external_binread = File.binread(DEFAULT_EXTERNAL_PATH).encoding.name
Expected: external=UTF-8 internal=nil locale=UTF-8 filesystem=UTF-8
Then, in a terminal (the local network from rake network:start):
- Write the file as bytes
icp canister call backend default_external_write '()' -e localExpected:
wrote 32 bytes - Upgrade the canister with the same module
icp deploy backend -e localThen:
default_external is set again on the upgrade's boot - The setting after the upgrade
icp canister call backend default_external_env '()' -e localExpected:
external=UTF-8 internal=nil locale=UTF-8 filesystem=UTF-8 - The file still reads as text
icp canister call backend default_external_read '()' -e localExpected:
UTF-8 25 true 9
State across calls
State kept across messages since 0.1.0
Each call is one update message to the same canister, and the Ruby heap carries on between them, so the count goes up by one every time. State in a Ruby global is shared by every visitor and is lost when the canister is upgraded.
Ruby source
# State kept across messages
#
# Each call is one update message to the same canister, and the Ruby heap
# carries on between them, so the count goes up by one every time. State in a
# Ruby global is shared by every visitor and is lost when the canister is
# upgraded.
# Stateful: yes
#
# Ported from ruby_wasm_builder challenges/counter (introduced 0.1.0, ported at 0.6.0).
$counter_count = 0
def counter_main
($counter_count += 1).to_s
end
On a fresh canister:
1
2
3
Declared methods since 0.3.0
Two updates, each served by the top-level method of its name and sharing one VM. `bump` counts; `total` reads the count and replies in a different shape. The shared count shows one VM, and the different replies show one method per export.
Ruby source
# Declared methods
#
# Two updates, each served by the top-level method of its name and sharing one
# VM. `bump` counts; `total` reads the count and replies in a different shape.
# The shared count shows one VM, and the different replies show one method per
# export.
# Stateful: yes
# Methods: bump, total
#
# Ported from ruby_wasm_builder challenges/declared_methods (introduced 0.3.0, ported at 0.6.0).
$declared_methods_n = 0
def declared_methods_bump = ($declared_methods_n += 1).to_s
def declared_methods_total = "total=#{$declared_methods_n}"
On a fresh canister:
bump => 1
bump => 2
total => total=2
Calls, errors and rollback
A bounded refusal since 0.3.0
A refusal's text is cut to at most 1,024 bytes, on a character boundary, and ends with " [truncated]". Here the reason is 1,001 `a`s followed by ten emoji, so the cut lands inside the first emoji and backs up to keep it whole or leave it out. A call afterwards still works: the refusal rolled back cleanly.
Ruby source
# A bounded refusal
#
# A refusal's text is cut to at most 1,024 bytes, on a character boundary, and
# ends with " [truncated]". Here the reason is 1,001 `a`s followed by ten
# emoji, so the cut lands inside the first emoji and backs up to keep it whole
# or leave it out. A call afterwards still works: the refusal rolled back
# cleanly.
# Outcome: refusal
# Methods: long_refusal, read
#
# Ported from ruby_wasm_builder challenges/trap_text_bound (introduced 0.3.0, ported at 0.6.0).
def trap_text_bound_long_refusal = IC.refuse("a" * 1001 + "\u{1F600}" * 10)
def trap_text_bound_read = "ok"
Expected: a refusal of 1013 characters, starting aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
A fault undoes the whole call since 0.3.0
`seed` commits a file and a value in the Ruby heap. Then a call overwrites the file, creates a second one, changes the heap, and raises an ordinary Ruby error that nobody rescues. The call ends as a fault, and everything it did is rolled back: the next `read` still sees the seeded file, no second file, and the seeded heap value.
Ruby source
# A fault undoes the whole call
#
# `seed` commits a file and a value in the Ruby heap. Then a call overwrites the
# file, creates a second one, changes the heap, and raises an ordinary Ruby
# error that nobody rescues. The call ends as a fault, and everything it did is
# rolled back: the next `read` still sees the seeded file, no second file, and
# the seeded heap value.
# Script: yes
#
# Ported from ruby_wasm_builder challenges/fault_undoes (introduced 0.3.0, ported at 0.6.0).
FAULT_UNDOES_KEPT = File.join(IC::DATA_DIR, "fault_undoes_kept.txt")
FAULT_UNDOES_NEW = File.join(IC::DATA_DIR, "fault_undoes_new.txt")
$fault_undoes_heap = 0
def fault_undoes_seed
File.write(FAULT_UNDOES_KEPT, "before")
$fault_undoes_heap = 1
"seeded"
end
def fault_undoes_write_then_raise
File.write(FAULT_UNDOES_KEPT, "after")
File.write(FAULT_UNDOES_NEW, "made")
$fault_undoes_heap = 2
raise ArgumentError, "x"
end
def fault_undoes_read
"kept=#{File.read(FAULT_UNDOES_KEPT)} new=#{File.exist?(FAULT_UNDOES_NEW) ? File.read(FAULT_UNDOES_NEW) : 'absent'} heap=#{$fault_undoes_heap}"
end
A refusal undoes the whole call since 0.3.0
The same shape as a fault, with a deliberate refusal in place of an error: `seed` commits a file and a heap value, then a call overwrites the file, creates another, changes the heap, and refuses with `IC.refuse`. The canister rolls all three back, and `read` shows what is left: the seeded values only.
Ruby source
# A refusal undoes the whole call
#
# The same shape as a fault, with a deliberate refusal in place of an error:
# `seed` commits a file and a heap value, then a call overwrites the file,
# creates another, changes the heap, and refuses with `IC.refuse`. The canister
# rolls all three back, and `read` shows what is left: the seeded values only.
# Script: yes
#
# Ported from ruby_wasm_builder challenges/refusal_undoes (introduced 0.3.0, ported at 0.6.0).
REFUSAL_UNDOES_KEPT = File.join(IC::DATA_DIR, "refusal_undoes_kept.txt")
REFUSAL_UNDOES_NEW = File.join(IC::DATA_DIR, "refusal_undoes_new.txt")
$refusal_undoes_heap = 0
def refusal_undoes_seed
File.write(REFUSAL_UNDOES_KEPT, "before")
$refusal_undoes_heap = 1
"seeded"
end
def refusal_undoes_write_then_refuse
File.write(REFUSAL_UNDOES_KEPT, "after")
File.write(REFUSAL_UNDOES_NEW, "made")
$refusal_undoes_heap = 2
IC.refuse("no")
end
def refusal_undoes_read
"kept=#{File.read(REFUSAL_UNDOES_KEPT)} new=#{File.exist?(REFUSAL_UNDOES_NEW) ? File.read(REFUSAL_UNDOES_NEW) : 'absent'} heap=#{$refusal_undoes_heap}"
end
A query keeps nothing since 0.5.0
`scribble` is a query. It writes a byte to a file and counts in the heap, then reports what it sees, so two in a row each see exactly one: whatever a query writes is thrown away. The update `look` then sees none, even after a refusal in a query. `scribble` also reads the canister's clock against the time `look` kept, and `who` returns the caller. (The builder's version also moves the clock on an hour and calls `who` as a second caller; a live network can do neither, so `hours` reads 0 here.)
Ruby source
# A query keeps nothing
#
# `scribble` is a query. It writes a byte to a file and counts in the heap, then
# reports what it sees, so two in a row each see exactly one: whatever a query
# writes is thrown away. The update `look` then sees none, even after a refusal
# in a query. `scribble` also reads the canister's clock against the time `look`
# kept, and `who` returns the caller. (The builder's version also moves the
# clock on an hour and calls `who` as a second caller; a live network can do
# neither, so `hours` reads 0 here.)
# Script: yes
#
# Ported from ruby_wasm_builder challenges/query_discards (introduced 0.5.0, ported at 0.6.0).
QUERY_DISCARDS_NOTES = File.join(IC::DATA_DIR, "query_discards_notes")
QUERY_DISCARDS_HOUR = 3600 * 1_000_000_000
$query_discards_heap = 0
$query_discards_looked_at = nil
def query_discards_notes_size = File.exist?(QUERY_DISCARDS_NOTES) ? File.size(QUERY_DISCARDS_NOTES) : 0
def query_discards_look
$query_discards_looked_at = IC.time
"file=#{query_discards_notes_size} heap=#{$query_discards_heap}"
end
def query_discards_scribble
File.write(QUERY_DISCARDS_NOTES, "q", mode: "a")
$query_discards_heap += 1
"file=#{query_discards_notes_size} heap=#{$query_discards_heap} hours=#{(IC.time - $query_discards_looked_at) / QUERY_DISCARDS_HOUR}"
end
def query_discards_refuse_in_query
File.write(QUERY_DISCARDS_NOTES, "q", mode: "a")
IC.refuse("a query keeps nothing")
end
def query_discards_who = IC.caller.to_s
Candid
Every Candid primitive round-trips since 0.6.0
Each `echo_*` method returns its argument unchanged, so a value that comes back different would show a decoding or encoding fault. The values sit at the edges: each sized integer's limits, numbers past 2^30 and 2^70, -0.0 and 1e308, a float32 that has to round, multibyte text with a NUL, every byte in a blob, empty and long vecs, a nested record, and a variant with and without a payload. `describe` also reports which Ruby type each field arrives as, which an echo cannot see.
Ruby source
# Every Candid primitive round-trips
#
# Each `echo_*` method returns its argument unchanged, so a value that comes back
# different would show a decoding or encoding fault. The values sit at the edges:
# each sized integer's limits, numbers past 2^30 and 2^70, -0.0 and 1e308, a
# float32 that has to round, multibyte text with a NUL, every byte in a blob,
# empty and long vecs, a nested record, and a variant with and without a payload.
# `describe` also reports which Ruby type each field arrives as, which an echo
# cannot see.
# Cases: yes
#
# Ported from ruby_wasm_builder challenges/candid_primitives (introduced 0.6.0, ported at 0.6.0).
%i[nat nat8 nat16 nat32 nat64 int int8 int16 int32 int64 float32 float64 bool text null reserved
principal blob opt vec record variant].each do |t|
Object.send(:define_method, :"candid_primitives_echo_#{t}") { |v| v }
Object.send(:private, :"candid_primitives_echo_#{t}")
end
def candid_primitives_take_empty(_) = "unreachable"
def candid_primitives_kind_of_value(v)
case v
when nil then "nil"
when String then "String/#{v.encoding}"
when Hash then "Hash/#{v.keys.map { |k| k.class.name }.uniq.join('+')}"
else v.class.name
end
end
def candid_primitives_describe(e) = e.map { |k, v| "#{k}=#{candid_primitives_kind_of_value(v)}" }.join(" ")
A reply that doesn't fit its declared type is a fault since 0.6.0
Each method returns a value that breaks one rule of Candid's strict encoding: a number out of range, a Float or String for an Integer, an extra or missing record field, an unknown variant case, bytes that aren't UTF-8. The call then faults with a message naming the method, the path and the type. Two things are allowed: an Integer where a float is declared, and an absent optional field. The reply is encoded after the handler has returned, so a file the handler wrote is still undone when the encoding faults.
Ruby source
# A reply that doesn't fit its declared type is a fault
#
# Each method returns a value that breaks one rule of Candid's strict encoding:
# a number out of range, a Float or String for an Integer, an extra or missing
# record field, an unknown variant case, bytes that aren't UTF-8. The call then
# faults with a message naming the method, the path and the type. Two things are
# allowed: an Integer where a float is declared, and an absent optional field.
# The reply is encoded after the handler has returned, so a file the handler
# wrote is still undone when the encoding faults.
# Cases: yes
#
# Ported from ruby_wasm_builder challenges/candid_strict_reply (introduced 0.6.0, ported at 0.6.0).
CANDID_STRICT_REPLY_FILE = File.join(IC::DATA_DIR, "candid_strict_reply")
def candid_strict_reply_as_nat8 = 300
def candid_strict_reply_as_nat(which) = { "float" => 3.0, "string" => "3" }.fetch(which)
def candid_strict_reply_as_record(which)
{ "extra" => { a: 1, b: nil, c: 2 }, "string_key" => { "a" => 1 }, "missing" => { b: "x" },
"absent_opt" => { a: 1 } }.fetch(which)
end
def candid_strict_reply_as_variant(which) = { "unknown" => { nope: 1 }, "bare" => :ok }.fetch(which)
def candid_strict_reply_as_text = "\xff".b
def candid_strict_reply_as_principal = IC.caller.to_s
def candid_strict_reply_as_empty = nil
def candid_strict_reply_as_float = 3
def candid_strict_reply_report = { items: [{ amount: 1 }, { amount: 2 }, { amount: 300 }] }
def candid_strict_reply_write_then_bad
File.write(CANDID_STRICT_REPLY_FILE, "written")
300
end
def candid_strict_reply_written = File.exist?(CANDID_STRICT_REPLY_FILE)
Candid subtyping on the way in command line, since 0.6.0
A canister decodes what it receives by the types it declared, and Candid's subtyping decides what is accepted. An extra record field is skipped, a missing optional field is null, a `nat` is accepted where an `int` is declared, a known variant case is accepted, and an `opt` whose content doesn't fit becomes null. What the rules refuse is refused with a reason: a missing required field, an `int` where a `nat` is declared, an unknown variant case. Each rule has a line it accepts and a line it refuses. Each method echoes what it decoded.
A browser can only send values that fit the declared types, so these are run with icp canister call against your local network (rake network:start && rake deploy).
Ruby source
# Candid subtyping on the way in
#
# A canister decodes what it receives by the types it declared, and Candid's
# subtyping decides what is accepted. An extra record field is skipped, a missing
# optional field is null, a `nat` is accepted where an `int` is declared, a known
# variant case is accepted, and an `opt` whose content doesn't fit becomes null.
# What the rules refuse is refused with a reason: a missing required field, an
# `int` where a `nat` is declared, an unknown variant case. Each rule has a line
# it accepts and a line it refuses. Each method echoes what it decoded.
# Cli: only
#
# Ported from ruby_wasm_builder challenges/candid_subtyping (introduced 0.6.0, ported at 0.6.0).
def candid_subtyping_pair(p) = p
def candid_subtyping_full(f) = f
def candid_subtyping_widen(i) = i
def candid_subtyping_narrow(n) = n
def candid_subtyping_choose(c) = c
def candid_subtyping_maybe(o) = o
def candid_subtyping_two(first, second) = { first: first, second: second }
- An extra record field is checked and skipped sends
record { a = 1; b = opt "x"; c = true } : record { a : nat; b : opt text; c : bool }icp canister call backend candid_subtyping_pair --args-format hex '4449444c026e716c03617d6200637e01010101017801' -e localExpected:
(record { a = 1 : nat; b = opt "x" }) - A missing opt field is null sends
record { a = 2 } : record { a : nat }icp canister call backend candid_subtyping_pair --args-format hex '4449444c016c01617d010002' -e localExpected:
(record { a = 2 : nat; b = null }) - A missing field that is not opt is refused sends
record { a = 3 } : record { a : nat }icp canister call backend candid_subtyping_full --args-format hex '4449444c016c01617d010003' -e localExpected:
refused: cannot decode candid_subtyping_full's argument 1 at .b (text): missing - A nat where an int is declared sends
5 : naticp canister call backend candid_subtyping_widen --args-format hex '4449444c00017d05' -e localExpected:
(5 : int) - An int where a nat is declared is refused sends
5 : inticp canister call backend candid_subtyping_narrow --args-format hex '4449444c00017c05' -e localExpected:
refused: cannot decode candid_subtyping_narrow's argument 1 (nat): the caller sent int - A known variant case sends
variant { y = "ok" } : variant { y : text }icp canister call backend candid_subtyping_choose --args-format hex '4449444c016b017971010000026f6b' -e localExpected:
(variant { y = "ok" }) - An unknown variant case is refused sends
variant { z = 1 } : variant { z : nat }icp canister call backend candid_subtyping_choose --args-format hex '4449444c016b017a7d01000001' -e localExpected:
refused: cannot decode candid_subtyping_choose's argument 1 (CandidSubtypingChoice): the case with hash 122 is not one of its cases - An opt whose content does not fit is null sends
opt -1 : opt inticp canister call backend candid_subtyping_maybe --args-format hex '4449444c016e7c0100017f' -e localExpected:
(null) - A bare value that fits is the opt sends
5 : naticp canister call backend candid_subtyping_maybe --args-format hex '4449444c00017d05' -e localExpected:
(opt (5 : nat)) - An extra argument is skipped sends
1 : nat, 2 : nat, 3 : naticp canister call backend candid_subtyping_two --args-format hex '4449444c00037d7d7d010203' -e localExpected:
(record { first = 1 : nat; second = opt (2 : nat) }) - A missing opt argument is null sends
1 : naticp canister call backend candid_subtyping_two --args-format hex '4449444c00017d01' -e localExpected:
(record { first = 1 : nat; second = null }) - Any other missing argument is refused sends
(no arguments)icp canister call backend candid_subtyping_two --args-format hex '4449444c0000' -e localExpected:
refused: cannot decode candid_subtyping_two's argument 1 (nat): missing
Hostile Candid is refused before it runs command line, since 0.6.0
A caller can send anything. A message that isn't Candid, that ends early, that claims a text of 4 GB, that nests types 33 deep or loops, or that puts a value where none can fit, ends in a refusal with an exact reason, before anything is allocated and before the handler runs. Every handler counts the calls it handled, so a final `count` shows no hostile call reached one and the canister still answers.
A browser can only send values that fit the declared types, so these are run with icp canister call against your local network (rake network:start && rake deploy). The counts assume a fresh canister.
Ruby source
# Hostile Candid is refused before it runs
#
# A caller can send anything. A message that isn't Candid, that ends early, that
# claims a text of 4 GB, that nests types 33 deep or loops, or that puts a value
# where none can fit, ends in a refusal with an exact reason, before anything is
# allocated and before the handler runs. Every handler counts the calls it
# handled, so a final `count` shows no hostile call reached one and the canister
# still answers.
# Stateful: yes
# Cli: only
#
# Ported from ruby_wasm_builder challenges/candid_refusals (introduced 0.6.0, ported at 0.6.0).
$candid_refusals_handled = 0
def candid_refusals_text(_) = ($candid_refusals_handled += 1)
def candid_refusals_nums(_) = ($candid_refusals_handled += 1)
def candid_refusals_small(_) = ($candid_refusals_handled += 1)
def candid_refusals_void(_) = ($candid_refusals_handled += 1)
def candid_refusals_count = $candid_refusals_handled
- A valid call first
icp canister call backend candid_refusals_text '("first")' -e localExpected:
(1 : nat) - Not Candid at all
icp canister call backend candid_refusals_text --args-format hex '5844494c0000' -e localExpected:
refused: cannot decode candid_refusals_text's arguments: not a Candid message: it does not start with DIDL - Cut short
icp canister call backend candid_refusals_text --args-format hex '4449444c000171' -e localExpected:
refused: cannot decode candid_refusals_text's argument 1: the message ends early, at byte 7 - Bytes after the last value
icp canister call backend candid_refusals_text --args-format hex '4449444c0001710178ff' -e localExpected:
refused: cannot decode candid_refusals_text's arguments: 1 byte(s) after the last value - A text that lies about its length
icp canister call backend candid_refusals_text --args-format hex '4449444c0001710578' -e localExpected:
refused: cannot decode candid_refusals_text's argument 1: a text claims 5 bytes, more than the 1 byte(s) left - A vec that claims 4 billion elements
icp canister call backend candid_refusals_nums --args-format hex '4449444c016d7d0100ffffffff0f' -e localExpected:
refused: cannot decode candid_refusals_nums's argument 1: a vec claims 4294967295 elements, more than the 0 byte(s) left - Zero-size elements past 10,000
icp canister call backend candid_refusals_nums --args-format hex '4449444c026d7d6d7f02000100914e' -e localExpected:
refused: cannot decode candid_refusals_nums's argument 2: a vec of zero-size elements claims more than 10000 - A number past 10 LEB128 bytes
icp canister call backend candid_refusals_nums --args-format hex '4449444c016d7d0100018080808080808080808001' -e localExpected:
refused: cannot decode candid_refusals_nums's argument 1: a LEB128 number runs past 10 bytes - A nat for a nat8
icp canister call backend candid_refusals_small --args-format hex '4449444c00017dac02' -e localExpected:
refused: cannot decode candid_refusals_small's argument 1 (nat8): the caller sent nat - A text that is not UTF-8
icp canister call backend candid_refusals_text --args-format hex '4449444c00017101ff' -e localExpected:
refused: cannot decode candid_refusals_text's argument 1 (text): its bytes are not valid UTF-8 - A value for empty
icp canister call backend candid_refusals_void --args-format hex '4449444c00016f' -e localExpected:
refused: cannot decode candid_refusals_void's argument 1 (empty): no value fits empty - A cyclic type table
icp canister call backend candid_refusals_nums --args-format hex '4449444c016e00010000' -e localExpected:
refused: cannot decode candid_refusals_nums's arguments: the type table has a cycle, and recursive types are not accepted - A cyclic type table, only in an extra argument that would be skipped
icp canister call backend candid_refusals_text --args-format hex '4449444c016e00027100017800' -e localExpected:
refused: cannot decode candid_refusals_text's arguments: the type table has a cycle, and recursive types are not accepted - A type table nested 33 deep
icp canister call backend candid_refusals_nums --args-format hex '4449444c216d016d026d036d046d056d066d076d086d096d0a6d0b6d0c6d0d6d0e6d0f6d106d116d126d136d146d156d166d176d186d196d1a6d1b6d1c6d1d6d1e6d1f6d206d7d010000' -e localExpected:
refused: cannot decode candid_refusals_nums's arguments: the type table nests deeper than 32 - A type table nested 33 deep, only in a skipped extra argument
icp canister call backend candid_refusals_text --args-format hex '4449444c216d016d026d036d046d056d066d076d086d096d0a6d0b6d0c6d0d6d0e6d0f6d106d116d126d136d146d156d166d176d186d196d1a6d1b6d1c6d1d6d1e6d1f6d206d7d027100017800' -e localExpected:
refused: cannot decode candid_refusals_text's arguments: the type table nests deeper than 32 - Still answering
icp canister call backend candid_refusals_nums '(vec { 1; 2 })' -e localExpected:
(2 : nat) - No hostile call reached a handler: two were handled
icp canister call backend candid_refusals_count '()' -e localExpected:
(2 : nat)
Who is calling
Who is calling since 0.5.0
`whoami` replies with the caller's principal as text. `remember` keeps one caller's principal as bytes, and `compare` reads it back and says how the current caller relates to it: equal (`==`), usable as the same Hash key, anonymous or not, and whether its text form round-trips. A different authenticated caller must say `same=false`; if every caller arrived as one principal it would say `true`.
Ruby source
# Who is calling
#
# `whoami` replies with the caller's principal as text. `remember` keeps one
# caller's principal as bytes, and `compare` reads it back and says how the
# current caller relates to it: equal (`==`), usable as the same Hash key,
# anonymous or not, and whether its text form round-trips. A different
# authenticated caller must say `same=false`; if every caller arrived as one
# principal it would say `true`.
# Script: yes
#
# Ported from ruby_wasm_builder challenges/caller (introduced 0.5.0, ported at 0.6.0).
CALLER_REMEMBERED = File.join(IC::DATA_DIR, "caller_remembered")
def caller_whoami = IC.caller.to_s
def caller_remember
File.binwrite(CALLER_REMEMBERED, IC.caller.bytes)
"remembered"
end
def caller_compare
caller = IC.caller
stored = IC::Principal.new(File.binread(CALLER_REMEMBERED))
"same=#{caller == stored} key=#{{ stored => 1 }.key?(caller)} anonymous=#{caller.anonymous?} " \
"roundtrip=#{IC::Principal.from_text(caller.to_s) == caller}"
end
Who controls the canister since 0.5.0
A controller is an identity that may manage the canister: here, whoever deployed it. `is_controller` says whether the caller is one, and `guarded` is a guard that refuses everyone else. `booted_by` is the principal that installed (or last upgraded) the canister, recorded each time the VM starts. A browser never holds your deployer key, so the browser steps show the not-a-controller side and the commands below show the controller side.
Ruby source
# Who controls the canister
#
# A controller is an identity that may manage the canister: here, whoever
# deployed it. `is_controller` says whether the caller is one, and `guarded` is
# a guard that refuses everyone else. `booted_by` is the principal that
# installed (or last upgraded) the canister, recorded each time the VM starts.
# A browser never holds your deployer key, so the browser steps show the
# not-a-controller side and the commands below show the controller side.
# Script: yes
# Cli: yes
#
# Ported from ruby_wasm_builder challenges/controller (introduced 0.5.0, ported at 0.6.0).
$controller_booted_by = IC.caller
def controller_booted_by = $controller_booted_by.to_s
def controller_is_controller = "controller=#{IC.caller.controller?}"
def controller_guarded
IC.caller.controller? || IC.refuse("only a controller may do this")
"allowed"
end
The controller is whoever deployed the canister, which a browser can't be, so run these with icp canister call against your local network (rake network:start && rake deploy); the default identity is the controller.
- The controller may pass the guard
icp canister call backend controller_guarded '()' -e localExpected:
("allowed") - You are a controller
icp canister call backend controller_is_controller '()' -e localExpected:
("controller=true") - Anonymous is refused by the same guard
icp canister call backend controller_guarded '()' -e local --identity anonymousExpected:
refused: only a controller may do this - Who installed the canister
icp canister call backend controller_booted_by '()' -e localThen:
your own principal; compare with `icp identity principal`
State and time
What survives an upgrade since 0.3.0
Each call counts twice: in a Ruby global (the heap) and in a file. Upgrade the canister yourself with the command below and call again. The heap is discarded by an upgrade, so it starts again at 1; the file lives in stable memory, so its count carries on. State you want to keep belongs in a file.
Ruby source
# What survives an upgrade
#
# Each call counts twice: in a Ruby global (the heap) and in a file. Upgrade the
# canister yourself with the command below and call again. The heap is discarded
# by an upgrade, so it starts again at 1; the file lives in stable memory, so
# its count carries on. State you want to keep belongs in a file.
# Stateful: yes
# Cli: yes
#
# Ported from ruby_wasm_builder challenges/upgrade (introduced 0.3.0, ported at 0.6.0).
UPGRADE_FILE = File.join(IC::DATA_DIR, "upgrade_n")
$upgrade_heap = 0
def upgrade_main
$upgrade_heap += 1
n = (File.exist?(UPGRADE_FILE) ? File.read(UPGRADE_FILE).to_i : 0) + 1
File.write(UPGRADE_FILE, n.to_s)
"n=#{n} heap=#{$upgrade_heap}"
end
Then, in a terminal (the local network from rake network:start):
- Upgrade the canister with the same module
icp deploy backend -e localThen:
the heap count starts again at 1, and the file count carries on
Demo-scale state survives an upgrade since 0.4.0
1,000 files of 1 KiB and a 10,000-line log are written in batches, then the canister is upgraded. Every boot, including the upgrade's, rebuilds an index of all of them, and `check` reports how many files and lines are intact and how many each index found at the last boot: 0 before an upgrade, 1000 and 10000 after. Updating one file and appending to a log are single cheap calls. Load 10 batches of each (×10 does that for you), then upgrade with the command below and `check` again. The log stops at 10,100 lines so it can't grow forever.
Ruby source
# Demo-scale state survives an upgrade
#
# 1,000 files of 1 KiB and a 10,000-line log are written in batches, then the
# canister is upgraded. Every boot, including the upgrade's, rebuilds an index
# of all of them, and `check` reports how many files and lines are intact and
# how many each index found at the last boot: 0 before an upgrade, 1000 and
# 10000 after. Updating one file and appending to a log are single cheap calls.
# Load 10 batches of each (×10 does that for you), then upgrade with the command
# below and `check` again. The log stops at 10,100 lines so it can't grow forever.
# Stateful: yes
# Methods: load_units, append_lines, check, rewrite_one, append_one
# Repeat: load_units, append_lines
# Cli: yes
#
# Ported from ruby_wasm_builder challenges/persist_both (introduced 0.4.0, ported at 0.6.0).
PERSIST_BOTH_UNITS = File.join(IC::DATA_DIR, "persist_both_units")
PERSIST_BOTH_LOG = File.join(IC::DATA_DIR, "persist_both_log.txt")
PERSIST_BOTH_PATTERN = "abcdefghijklmnopqrstuvwxyz0123456789"
PERSIST_BOTH_FILES = 1000
PERSIST_BOTH_UNIT_BATCH = 100
PERSIST_BOTH_LINES = 10_000
PERSIST_BOTH_LINES_MAX = 10_100
PERSIST_BOTH_LINE_BATCH = 1_000
PERSIST_BOTH_WIDTH = 256
def persist_both_rotated(i)
r = i % PERSIST_BOTH_PATTERN.size
PERSIST_BOTH_PATTERN[r..] + PERSIST_BOTH_PATTERN[0, r]
end
def persist_both_unit_name(i) = format("unit-%04d.dat", i)
def persist_both_unit_bytes(i) = format("%04d:", i) + (persist_both_rotated(i) * 29)[0, 1019]
def persist_both_unit_names = Dir.exist?(PERSIST_BOTH_UNITS) ? Dir.children(PERSIST_BOTH_UNITS) : []
def persist_both_log_line(i) = format("%05d ", i) + (persist_both_rotated(i) * 7)[0, 249] + "\n"
def persist_both_lines_written = File.exist?(PERSIST_BOTH_LOG) ? File.size(PERSIST_BOTH_LOG) / PERSIST_BOTH_WIDTH : 0
# Both indexes on every boot; no call changes either.
$persist_both_unit_index = {}
persist_both_unit_names.each { |n| $persist_both_unit_index[n] = File.read(File.join(PERSIST_BOTH_UNITS, n))[0, 4] }
$persist_both_line_index = {}
if File.exist?(PERSIST_BOTH_LOG)
File.foreach(PERSIST_BOTH_LOG).with_index { |line, n| $persist_both_line_index[line[0, 5]] = n }
end
def persist_both_load_units
Dir.mkdir(PERSIST_BOTH_UNITS) unless Dir.exist?(PERSIST_BOTH_UNITS)
from = persist_both_unit_names.size
(from...[from + PERSIST_BOTH_UNIT_BATCH, PERSIST_BOTH_FILES].min).each do |i|
File.write(File.join(PERSIST_BOTH_UNITS, persist_both_unit_name(i)), persist_both_unit_bytes(i))
end
"files=#{persist_both_unit_names.size}"
end
def persist_both_append_lines
from = persist_both_lines_written
File.open(PERSIST_BOTH_LOG, "a") do |f|
f.write((from...[from + PERSIST_BOTH_LINE_BATCH, PERSIST_BOTH_LINES].min).map { |i| persist_both_log_line(i) }.join)
end
"lines=#{persist_both_lines_written}"
end
def persist_both_check
files = (0...PERSIST_BOTH_FILES).count do |i|
path = File.join(PERSIST_BOTH_UNITS, persist_both_unit_name(i))
File.exist?(path) && File.read(path) == persist_both_unit_bytes(i)
end
lines = File.exist?(PERSIST_BOTH_LOG) ? File.read(PERSIST_BOTH_LOG).lines : []
intact = lines.each_with_index.count { |line, i| line == persist_both_log_line(i) }
"files=#{persist_both_unit_names.size} intact=#{files} lines=#{lines.size} intact=#{intact} " \
"boot_index=#{$persist_both_unit_index.size}/#{$persist_both_line_index.size}"
end
def persist_both_rewrite_one
File.write(File.join(PERSIST_BOTH_UNITS, persist_both_unit_name(500)), persist_both_unit_bytes(500))
"rewrote unit-0500"
end
def persist_both_append_one
IC.refuse("the log is full (#{PERSIST_BOTH_LINES_MAX} lines)") if persist_both_lines_written >= PERSIST_BOTH_LINES_MAX
File.open(PERSIST_BOTH_LOG, "a") { |f| f.write(persist_both_log_line(persist_both_lines_written)) }
"lines=#{persist_both_lines_written}"
end
Then, in a terminal (the local network from rake network:start):
- Upgrade the canister with the same module (it re-reads every file and line at boot, so allow about ten seconds)
icp deploy backend -e localThen:
press check again: boot_index now reads 1000/10000
The Internet Computer's clock since 0.5.0
`IC.time` is an Integer of nanoseconds that never goes backwards, and `Time.now` reads the same clock within a message. `date` is the canister's date, which should be today's. `start` then `lapsed` measures a lapse of a day in Ruby: `lapsed` reads false right after `start`, and only true once a real 24 hours have passed. (The builder's version sets and advances the clock; a live network can't, and `lapsed` refuses until `start` has been called, so a call out of order is a clear message and not an error.)
Ruby source
# The Internet Computer's clock
#
# `IC.time` is an Integer of nanoseconds that never goes backwards, and
# `Time.now` reads the same clock within a message. `date` is the canister's
# date, which should be today's. `start` then `lapsed` measures a lapse of a day
# in Ruby: `lapsed` reads false right after `start`, and only true once a real
# 24 hours have passed. (The builder's version sets and advances the clock; a
# live network can't, and `lapsed` refuses until `start` has been called, so a
# call out of order is a clear message and not an error.)
# Script: yes
#
# Ported from ruby_wasm_builder challenges/time (introduced 0.5.0, ported at 0.6.0).
TIME_NANOS = 1_000_000_000
TIME_DAY = 24 * 3600 * TIME_NANOS
$time_last = 0
$time_start = nil
def time_tick
t = IC.time
now = Time.now
forward = t >= $time_last
$time_last = t
"integer=#{t.is_a?(Integer)} forward=#{forward} time_now=#{now.to_i * TIME_NANOS + now.nsec == t}"
end
def time_date = Time.at(IC.time / TIME_NANOS).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
def time_start
$time_start = IC.time
"started"
end
def time_lapsed
IC.refuse("call start first") unless $time_start
"lapsed=#{IC.time - $time_start >= TIME_DAY}"
end
Web apps over HTTP
Web pages straight from the canister web, since 0.10.0
The canister answers the IC's HTTP gateway itself, so a browser can open its pages, with no frontend in between. The gateway calls `http_request` as a query. A query keeps nothing it writes, so anything that changes state replies `upgrade: true`, and the gateway calls `http_request_update` instead, through consensus. Headers arrive and leave as [name, value] pairs.
The whole interface is one line in ic-interface.rb, `http_gateway certification: :skip` (0.10.0). It declares the gateway's types and both methods, and serves every page on the canister's normal domain, unverified: the canister certifies that no reply needs checking. The same app ran in 0.7.0's `http_request` Challenge with those types written out by hand, and in 0.8.0's `http_gateway_word` with the bare word `http_gateway`, which serves only the raw domain. A canister has one declaration, and this one's is :skip. Its routes live under /challenges/http_certified_skip/ here; the rows below call the two methods as the gateway would, and each GET opens through the real gateway. `/visits` counts across every visitor and resets on upgrade.
Ruby source
# Web pages straight from the canister
#
# The canister answers the IC's HTTP gateway itself, so a browser can open its
# pages, with no frontend in between. The gateway calls `http_request` as a
# query. A query keeps nothing it writes, so anything that changes state
# replies `upgrade: true`, and the gateway calls `http_request_update` instead,
# through consensus. Headers arrive and leave as [name, value] pairs.
#
# The whole interface is one line in ic-interface.rb, `http_gateway
# certification: :skip` (0.10.0). It declares the gateway's types and both
# methods, and serves every page on the canister's normal domain, unverified:
# the canister certifies that no reply needs checking. The same app ran in
# 0.7.0's `http_request` Challenge with those types written out by hand, and
# in 0.8.0's `http_gateway_word` with the bare word `http_gateway`, which
# serves only the raw domain. A canister has one declaration, and this one's is
# :skip. Its routes live under /challenges/http_certified_skip/ here; the rows
# below call the two methods as the gateway would, and each GET opens through
# the real gateway. `/visits` counts across every visitor and resets on upgrade.
# Stateful: yes
# Http: yes
#
# Ported from ruby_wasm_builder challenges/http_certified_skip (introduced 0.10.0, ported at 0.10.0).
$http_certified_skip_visits = 0
module HttpCertifiedSkip
TEXT = [["content-type", "text/plain"]].freeze
module_function
def reply(status, body, headers = TEXT) = { status_code: status, headers: headers, body: body, upgrade: nil }
def header(req, name) = req[:headers].find { |n, _| n.downcase == name }&.last
def query(req)
path = req[:url].split("?", 2).first
case [req[:method], path]
in ["GET", "/hello"] then reply(200, "hello from Ruby")
in ["GET", "/agent"] then reply(200, "agent=#{header(req, 'user-agent') || '-'}")
in ["GET", "/visits"] then reply(200, "visits: #{$http_certified_skip_visits}")
in [_, "/visit" | "/echo"] then { status_code: 200, headers: [], body: "", upgrade: true }
in ["GET", "/bad-header"] then reply(200, "x", ["content-type: text/plain"])
else reply(404, "not found: #{path}")
end
end
def update(req)
path = req[:url].split("?", 2).first
case [req[:method], path]
in ["POST", "/visit"] then reply(200, "visits: #{$http_certified_skip_visits += 1}")
in ["POST", "/echo"] then reply(200, "#{req[:body].bytesize} bytes: #{req[:body]}")
else reply(404, "not found: #{path}")
end
end
end
Rack with no Rack gem web, since 0.7.0
Any Rack app (anything answering `call(env)` with `[status, headers, body]`) served through the IC's HTTP gateway by about a hundred lines of plain Ruby, `IcRack` below: no gem at all. It builds the env from the gateway's request (method, path, query, server, headers as HTTP_*), gives the body as rack.input through a small reader (StringIO is a C extension the `minimal` build lacks), splits a header with two values into two, and closes a body after reading it. A query can't keep a write, so GET, HEAD and OPTIONS are answered by the query, and anything else is upgraded to an update; a GET that writes asks for the upgrade with an `x-ic-upgrade` header, never sent on. The `ruby_wasm_ic_rack` gem is this adapter, packaged; the Story Page runs on it. Its routes live under /challenges/rack_adapter/ here. `/visits` and `/closed` count across every visitor and reset on upgrade.
Ruby source
# Rack with no Rack gem
#
# Any Rack app (anything answering `call(env)` with `[status, headers, body]`)
# served through the IC's HTTP gateway by about a hundred lines of plain Ruby,
# `IcRack` below: no gem at all. It builds the env from the gateway's request
# (method, path, query, server, headers as HTTP_*), gives the body as
# rack.input through a small reader (StringIO is a C extension the `minimal`
# build lacks), splits a header with two values into two, and closes a body
# after reading it. A query can't keep a write, so GET, HEAD and OPTIONS are
# answered by the query, and anything else is upgraded to an update; a GET that
# writes asks for the upgrade with an `x-ic-upgrade` header, never sent on.
# The `ruby_wasm_ic_rack` gem is this adapter, packaged; the Story Page runs on
# it. Its routes live under /challenges/rack_adapter/ here. `/visits` and
# `/closed` count across every visitor and reset on upgrade.
# Stateful: yes
# Http: yes
#
# Ported from ruby_wasm_builder challenges/rack_adapter (introduced 0.7.0, ported at 0.10.0).
$rack_adapter_visits = 0
$rack_adapter_closed = 0
module RackAdapter
module IcRack
SAFE = %w[GET HEAD OPTIONS].freeze
UPGRADE = "x-ic-upgrade"
# rack.input over the body's bytes: what Rack 3 asks of an input stream.
class Input
def initialize(bytes)
@bytes = bytes.b
@pos = 0
end
def read(length = nil, buffer = nil)
rest = @bytes.byteslice(@pos, @bytes.bytesize - @pos) || "".b
chunk = length ? rest.byteslice(0, length) : rest
@pos += chunk.bytesize
return nil if length && length.positive? && chunk.empty?
buffer ? buffer.replace(chunk) : chunk
end
def gets
return nil if @pos >= @bytes.bytesize
stop = @bytes.index("\n", @pos)
line = @bytes.byteslice(@pos, (stop ? stop + 1 : @bytes.bytesize) - @pos)
@pos += line.bytesize
line
end
def each
while (line = gets)
yield line
end
end
def rewind = @pos = 0
def close = nil
end
module_function
# rack.input over the body. An Application may redefine it (roda_on_minimal
# gives a StringIO).
def input(bytes) = Input.new(bytes)
def env(req)
path, query = req[:url].split("?", 2)
host = header(req, "host").to_s
env = {
"REQUEST_METHOD" => req[:method],
"SCRIPT_NAME" => "",
"PATH_INFO" => path.to_s.empty? ? "/" : path,
"QUERY_STRING" => query.to_s,
"SERVER_NAME" => host.split(":").first.to_s,
"SERVER_PORT" => "443",
"SERVER_PROTOCOL" => "HTTP/1.1",
"rack.url_scheme" => "https",
"rack.input" => input(req[:body]),
"rack.errors" => $stderr
}
req[:headers].each do |name, value|
key = name.upcase.tr("-", "_")
key = "HTTP_#{key}" unless %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
env[key] = env.key?(key) ? "#{env[key]}, #{value}" : value
end
env
end
def header(req, name) = req[:headers].find { |n, _| n.downcase == name }&.last
# The gateway's response record from a Rack response. A header's value may
# be an Array, or lines joined by "\n" (Rack 3): one header each.
def response(status, headers, body)
pairs = headers.flat_map do |name, value|
Array(value).flat_map { |v| v.to_s.split("\n") }.map { |v| [name.to_s.downcase, v] }
end
bytes = +"".b
body.each { |part| bytes << part.b }
{ status_code: Integer(status), headers: pairs, body: bytes, upgrade: nil }
ensure
body.close if body.respond_to?(:close)
end
def upgrade = { status_code: 200, headers: [], body: "".b, upgrade: true }
# http_request: the query.
def query(app, req)
return upgrade unless SAFE.include?(req[:method])
status, headers, body = app.call(env(req))
if headers.keys.any? { |k| k.to_s.downcase == UPGRADE }
body.close if body.respond_to?(:close)
return upgrade
end
response(status, headers, body)
end
# http_request_update: the update, after an upgrade.
def update(app, req)
status, headers, body = app.call(env(req))
response(status, headers.reject { |k, _| k.to_s.downcase == UPGRADE }, body)
end
end
# A body that counts when the adapter closes it.
class Counted
def initialize(parts) = @parts = parts
def each(&) = @parts.each(&)
def close = $rack_adapter_closed += 1
end
TEXT = { "content-type" => "text/plain" }.freeze
ENV_KEYS = %w[REQUEST_METHOD SCRIPT_NAME PATH_INFO QUERY_STRING SERVER_NAME SERVER_PORT rack.url_scheme
HTTP_ACCEPT HTTP_X_BOOK CONTENT_TYPE].freeze
APP = lambda do |env|
case [env["REQUEST_METHOD"], env["PATH_INFO"]]
in ["GET", "/env"]
[200, TEXT, [ENV_KEYS.map { |k| "#{k}=#{env[k]}" }.join(";")]]
in ["GET", "/cookies"]
[200, TEXT.merge("set-cookie" => ["a=1", "b=2"], "x-lines" => "one\ntwo"), ["two cookies"]]
in ["GET", "/closed"]
[200, TEXT, Counted.new(["closed=#{$rack_adapter_closed}"])]
in ["GET", "/visits"]
[200, TEXT, ["visits=#{$rack_adapter_visits}"]]
in ["GET", "/visit"]
[200, TEXT.merge("x-ic-upgrade" => "1"), ["visits=#{$rack_adapter_visits += 1}"]]
in ["POST", "/books"]
input = env["rack.input"]
first = input.gets
rest = input.read
input.rewind
[201, TEXT, ["#{env['CONTENT_TYPE']} first=#{first.inspect} rest=#{rest.inspect} again=#{input.read(5)}"]]
else
[404, TEXT, ["no route for #{env['REQUEST_METHOD']} #{env['PATH_INFO']}"]]
end
end
def self.query(req) = IcRack.query(APP, req)
def self.update(req) = IcRack.update(APP, req)
end
Roda in a canister web, since 0.7.0
A Roda app, Books, served through the IC's HTTP gateway: routing, an Integer segment, query parameters (plain, percent-encoded UTF-8, and nested), a form post read from rack.input, a multipart form, the app's own 422, and the not_found plugin. Rack and Roda are ordinary gems, unmodified, and the `minimal` build's missing pieces (StringIO, StringScanner, Date and the Encoding constants, all C or built-in elsewhere) come from the `ruby_wasm_ic_minimal` gem. A GET is answered by the query; a POST is upgraded to an update, which keeps the new book for every visitor after it.
The builder's Challenge carries trimmed copies of Rack and Roda and the stand-ins inside the app. Here they are Installed Gems, sent to the canister after it is deployed and loaded at the next upgrade (the same ones the Story Page uses), and the gateway glue is the `ruby_wasm_ic_rack` gem. Its routes live under /challenges/roda_on_minimal/ here. The book list is shared by every visitor and resets on upgrade.
Ruby source
# Roda in a canister
#
# A Roda app, Books, served through the IC's HTTP gateway: routing, an Integer
# segment, query parameters (plain, percent-encoded UTF-8, and nested), a form
# post read from rack.input, a multipart form, the app's own 422, and the
# not_found plugin. Rack and Roda are ordinary gems, unmodified, and the
# `minimal` build's missing pieces (StringIO, StringScanner, Date and the
# Encoding constants, all C or built-in elsewhere) come from the
# `ruby_wasm_ic_minimal` gem. A GET is answered by the query; a POST is upgraded
# to an update, which keeps the new book for every visitor after it.
#
# The builder's Challenge carries trimmed copies of Rack and Roda and the
# stand-ins inside the app. Here they are Installed Gems, sent to the canister
# after it is deployed and loaded at the next upgrade (the same ones the Story
# Page uses), and the gateway glue is the `ruby_wasm_ic_rack` gem. Its routes
# live under /challenges/roda_on_minimal/ here. The book list is shared by
# every visitor and resets on upgrade.
# Stateful: yes
# Http: yes
#
# Ported from ruby_wasm_builder challenges/roda_on_minimal (introduced 0.7.0, ported at 0.10.0).
require "stringio" # ruby_wasm_ic_minimal's stand-in in the canister, the real one on MRI
$roda_on_minimal_books = { 1 => { title: "Noli Me Tangere", author: "Rizal" } }
module RodaOnMinimal
class Books < Roda
plugin :not_found do
response["content-type"] = "text/plain"
"no page at #{request.path}"
end
route do |r|
response["content-type"] = "text/plain"
r.on "books" do
r.get Integer do |id|
book = $roda_on_minimal_books[id] or next
"#{id}: #{book[:title]} (#{book[:author]})"
end
r.get "search" do
q = r.params["q"].to_s.downcase
hits = $roda_on_minimal_books.select { |_, b| b[:title].downcase.include?(q) }
"q=#{r.params['q']} page=#{r.params.fetch('page', '1')} hits=#{hits.keys.join(',')}"
end
r.get "filter" do
tags = r.params.fetch("tag", []).join(",")
"tags=#{tags} by.name=#{r.params.dig('by', 'name')}"
end
r.is do
r.get { $roda_on_minimal_books.map { |id, b| "#{id}: #{b[:title]}" }.join("\n") }
r.post do
title, author = r.params.values_at("title", "author")
if title.to_s.empty?
response.status = 422
next "title is required"
end
id = $roda_on_minimal_books.keys.max + 1
$roda_on_minimal_books[id] = { title: title, author: author }
response.status = 201
"created #{id}: #{title} (#{author}) via #{r.media_type}"
end
end
end
end
end
# The Challenge reads rack.input as a StringIO, as under any Rack server; the
# gem's own reader serves the Story Page, so this port wraps the env itself.
def self.call_app(kind, req)
env_app = ->(env) { Books.app.call(env.merge("rack.input" => StringIO.new(req[:body].b))) }
kind == :query ? RubyWasmIcRack.query(env_app, req) : RubyWasmIcRack.update(env_app, req)
end
def self.query(req) = call_app(:query, req)
def self.update(req) = call_app(:update, req)
end
An ever-growing story showcase, since 0.7.0
One word at a time, a story that everyone shares, on a page the canister serves itself: a Roda app on Rack, through the IC's HTTP gateway, with no frontend in between and no JavaScript. Reading the story is a query. Adding a word is a form post, which the gateway upgrades to an update, and the page redirects back to the story. A word is 1 to 24 characters with no spaces, or \n to end a paragraph, and each browser may add one every five minutes; a word that breaks a rule comes back with the reason and is not written. The story keeps its last 5,000 words in a file, so it survives an upgrade.
Ruby source
# An ever-growing story
#
# One word at a time, a story that everyone shares, on a page the canister
# serves itself: a Roda app on Rack, through the IC's HTTP gateway, with no
# frontend in between and no JavaScript. Reading the story is a query. Adding a
# word is a form post, which the gateway upgrades to an update, and the page
# redirects back to the story. A word is 1 to 24 characters with no spaces, or
# \n to end a paragraph, and each browser may add one every five minutes; a
# word that breaks a rule comes back with the reason and is not written. The
# story keeps its last 5,000 words in a file, so it survives an upgrade.
# Page: yes
#
# Showcase for ruby_wasm_builder challenges/roda_on_minimal (introduced 0.7.0, written for 0.10.0).
STORY_FILE = File.join(IC::DATA_DIR, "story")
STORY_MAX = 5_000
STORY_WORD_MAX = 24
STORY_WINDOW = 5 * 60 * 1_000_000_000 # nanoseconds
STORY_BREAK = "\\n" # the two characters backslash, n
STORY_COOKIE = "story_visitor"
# Every request through the gateway comes from the anonymous principal, so a
# Story Visitor is a browser, told apart by a cookie the page sets on its first
# word. Anyone can clear it: the limit is a courtesy, not security. Both
# tables live on the heap, so an upgrade forgets them.
$story_last_added = {} # visitor id => IC.time of its last word
$story_visitor_seq = 0
module Story
module_function
# One word per line; a blank line is a paragraph break. Since 0.9.0 a file
# read with no encoding is UTF-8, so a word's length counts characters.
def words
return [] unless File.exist?(STORY_FILE)
File.read(STORY_FILE).split("\n", -1).tap { |w| w.pop if w.last == "" }
end
def paragraphs = words.chunk_while { |a, b| a != "" && b != "" }.map { |p| p.reject(&:empty?) }.reject(&:empty?)
# Why `word` can't be added, or nil.
def problem(word)
return nil if word == STORY_BREAK
return nil if word.match?(/\A\S{1,#{STORY_WORD_MAX}}\z/)
"a word is 1 to #{STORY_WORD_MAX} characters with no spaces, or \\n to end a paragraph"
end
# Seconds until `visitor` may add another word, or nil if it may now.
def wait(visitor, now)
last = visitor && $story_last_added[visitor]
return nil unless last && now - last < STORY_WINDOW
((STORY_WINDOW - (now - last)) / 1_000_000_000.0).ceil
end
# Only in an update: a query keeps nothing it writes.
def add(word, visitor, now)
all = words << (word == STORY_BREAK ? "" : word)
File.write(STORY_FILE, all.last(STORY_MAX).join("\n") + "\n")
$story_last_added.delete_if { |_, at| now - at >= STORY_WINDOW }
$story_last_added[visitor] = now
end
# Unique within this canister's heap: the update's time, and a count of
# visitors met since the last upgrade.
def new_visitor(now) = "v#{now.to_s(36)}#{($story_visitor_seq += 1).to_s(36)}"
def escape(text) = text.to_s.gsub(/[&<>"']/, "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'")
end
class StoryPage < Roda
route do |r|
visitor = r.cookies[STORY_COOKIE]
r.root { page(visitor, added: r.params["added"]) }
r.get("story") { page(visitor, added: r.params["added"]) }
r.post "words" do
word = r.params["word"].to_s.strip
now = IC.time
fresh = visitor.nil? || visitor.empty?
visitor = Story.new_visitor(now) if fresh
if fresh
# Secure, except on a local network's plain-http *.localhost, where
# some browsers (and curl) would then never send it back.
secure = r.host.to_s.match?(/(\A|\.)localhost\z/) ? "" : "; Secure"
response["set-cookie"] = "#{STORY_COOKIE}=#{visitor}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly#{secure}"
end
reason = Story.problem(word)
if reason.nil? && (seconds = Story.wait(visitor, now))
reason = "one word per browser every 5 minutes: try again in #{seconds}s"
end
if reason
response.status = 422
next page(visitor, error: reason, word: word)
end
Story.add(word, visitor, now)
r.redirect("/?added=1", 303)
end
response.status = 404
response["content-type"] = "text/plain; charset=utf-8"
"not found: #{r.path}"
end
STYLE = <<~CSS
:root { --bg: #f2f2f2; --fg: #444; --heading: black; --action: #d64045; --muted: #777; }
body { background: var(--bg); color: var(--fg); margin: 0 auto; padding: 0 16px; max-width: 42rem;
font: 108%/1.5 BlinkMacSystemFont, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
h1 { margin: 2rem 0 1rem; text-align: center; font-weight: 900; font-size: 2.2rem; color: var(--heading); line-height: 1.2; }
.rules, footer { color: var(--muted); font-size: 0.9rem; }
.story { background: white; border-radius: 6px; padding: 0.5rem 1.25rem; margin: 1.5rem 0;
font-family: Georgia, "Times New Roman", serif; font-size: 1.15rem; }
.story p { overflow-wrap: anywhere; }
form { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
input[type=text] { flex: 1 1 12rem; font: inherit; padding: 0.4rem 0.6rem; border: 1px solid #bbb; border-radius: 4px; }
button { font: inherit; padding: 0.4rem 1rem; border: 0; border-radius: 4px; background: var(--action); color: white; cursor: pointer; }
.error { color: var(--action); font-weight: 600; }
.note { color: #2a7a3a; font-weight: 600; }
footer { margin: 3rem 0 2rem; border-top: 1px solid #ddd; padding-top: 1rem; }
code { font-size: 0.95em; }
CSS
private
def page(visitor, added: nil, error: nil, word: nil)
response["content-type"] = "text/html; charset=utf-8"
paras = Story.paragraphs
story = if paras.empty?
"<p><em>The story has not started. Add the first word.</em></p>"
else
paras.map { |p| "<p>#{Story.escape(p.join(' '))}</p>" }.join("\n")
end
count = Story.words.count { |w| w != "" }
wait = Story.wait(visitor, IC.time)
status = if error then %(<p class="error" role="alert">Not added: #{Story.escape(error)}</p>)
elsif added then %(<p class="note" role="status">Your word is in.</p>)
elsif wait then %(<p class="rules" role="status">You can add your next word in #{wait}s.</p>)
else ""
end
<<~HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>An ever-growing story</title>
<style>#{STYLE}</style>
</head>
<body>
<h1>An ever-growing story</h1>
<p class="rules">Add one word to a story everyone shares: 1 to #{STORY_WORD_MAX} characters with no spaces, or
<code>\\n</code> to end a paragraph. One word per browser every five minutes. #{count} #{count == 1 ? 'word' : 'words'} so far.</p>
<div class="story">
#{story}
</div>
#{status}
<form method="post" action="/words">
<label for="word">Your word</label>
<input id="word" name="word" type="text" autocomplete="off" required maxlength="#{STORY_WORD_MAX}" value="#{Story.escape(word)}">
<button type="submit">Add to the story</button>
</form>
<footer>
<p>This page is a Roda app on Rack, running in a Ruby canister on the Internet Computer and served straight
from it through the IC's HTTP gateway (ruby_wasm_builder 0.10.0). Rack and Roda are gems installed into the
canister after it was deployed.</p>
<p>Pages are served on the canister's normal domain but not certified (<code>certification: :skip</code>):
the gateway does not check that a reply came from the canister. This browser is told apart by a cookie, so
the five-minute limit is a courtesy, not security.</p>
<p>Part of the PhRUG ICP demo.</p>
</footer>
</body>
</html>
HTML
end
end
carried_stdlib, a challenge for apps that bring their own copy of a
standard library file, is not shown: it was an intermediate step, and only
matters to power users doing sophisticated customisation or optimisation.
Nor are persist_files and persist_log: each fills the shared
canister with thousands of files or lines and re-reads them all at every boot,
which would slow every upgrade this demo does. Demo-scale state survives an
upgrade, above, covers both. http_request (0.7.0) and
http_gateway_word (0.8.0) run the same app as Web pages straight
from the canister and differ only in how they declare the gateway; a
canister has one declaration, so only the 0.10.0 one is shown.