ikura 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +10 -0
- data/Gemfile +8 -0
- data/README.md +56 -0
- data/Rakefile +11 -0
- data/exe/ikura +6 -0
- data/lib/ikura/builder.rb +7 -0
- data/lib/ikura/server.rb +118 -0
- data/lib/ikura/templates/ikura.html.erb +114 -0
- data/lib/ikura/version.rb +5 -0
- data/lib/ikura.rb +8 -0
- data/sig/ikura.rbs +4 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 18b5d492ca45d8e35c47fc6ae5b776465b1c97bbbea3c6bc8685860ff006b638
|
|
4
|
+
data.tar.gz: 48a96a16a338ff94097e3b17c8435ec13c2746568fa5e5cade896cf158e2d623
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b28cb7014d7a2896230d81e19b24685f75a625164f48fb289146b27c4d29b06b0759d173d6421da7634b57c07c32c7838b9f918f7f9accba90dd0cad70b38dbe
|
|
7
|
+
data.tar.gz: 541e6c46e6a8708e03610caa899d511caa15db2a191423c2de5f8c9a8306682c803c69af68f4c9e53d198b3b875a6c4181d799d0896dfcd3e3e14b4b02e8e97a
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.2
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-04-04
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Initial release
|
|
8
|
+
- Interactive gunkan-maki sushi toy server
|
|
9
|
+
- Click on the sushi to place ikura (salmon roe) via Ruby running in WebAssembly
|
|
10
|
+
- Turbo Stream-based DOM updates without a frontend framework
|
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Ikura
|
|
2
|
+
|
|
3
|
+
An interactive gunkan-maki sushi toy server powered by Ruby Wasm.
|
|
4
|
+
|
|
5
|
+
Click anywhere on the sushi in the browser to place ikura (salmon roe). The click handling runs entirely in Ruby via WebAssembly, and DOM updates are delivered as Turbo Streams — no frontend framework required.
|
|
6
|
+
|
|
7
|
+
The server is built on Ruby's built-in `socket` library (TCPServer), so there are **no external runtime dependencies**.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
gem install ikura
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or add to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
bundle add ikura
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Start the server:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
ikura
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then open [http://localhost:8080](http://localhost:8080) in your browser.
|
|
30
|
+
|
|
31
|
+
To use a custom port:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require "ikura"
|
|
35
|
+
Ikura::Server.start(port: 3000)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Development
|
|
39
|
+
|
|
40
|
+
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt.
|
|
41
|
+
|
|
42
|
+
To install this gem onto your local machine:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
bundle exec rake install
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
To release a new version, update `lib/ikura/version.rb`, then run:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
bundle exec rake release
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Contributing
|
|
55
|
+
|
|
56
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/haruna-tsujita/ikura.
|
data/Rakefile
ADDED
data/exe/ikura
ADDED
data/lib/ikura/server.rb
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "erb"
|
|
6
|
+
|
|
7
|
+
module Ikura
|
|
8
|
+
class Server
|
|
9
|
+
TEMPLATE_PATH = File.join(__dir__, "templates", "ikura.html.erb")
|
|
10
|
+
|
|
11
|
+
def self.start(port: 8080)
|
|
12
|
+
new(port:).run
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
COORDS = [
|
|
16
|
+
[50, 50], [35, 50], [65, 50], [20, 50], [80, 50],
|
|
17
|
+
[50, 15], [35, 22], [65, 22], [20, 35], [80, 35],
|
|
18
|
+
[50, 85], [35, 78], [65, 78], [20, 65], [80, 65],
|
|
19
|
+
[10, 50], [90, 50],
|
|
20
|
+
[50, 30], [50, 70], [28, 38], [72, 38], [28, 62], [72, 62],
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(port: 8080)
|
|
24
|
+
@port = port
|
|
25
|
+
@ikura_count = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
server = TCPServer.new(@port)
|
|
30
|
+
$stdout.sync = true
|
|
31
|
+
puts "🍣 http://localhost:#{@port}"
|
|
32
|
+
puts " (Ctrl+C to stop)\n\n"
|
|
33
|
+
|
|
34
|
+
loop do
|
|
35
|
+
client = server.accept
|
|
36
|
+
req = parse_request(client)
|
|
37
|
+
next unless req
|
|
38
|
+
|
|
39
|
+
puts "#{req[:method]} #{req[:path]}"
|
|
40
|
+
handle(client, req)
|
|
41
|
+
client.close
|
|
42
|
+
rescue => e
|
|
43
|
+
STDERR.puts "Error: #{e}"
|
|
44
|
+
client&.close
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def handle(client, req)
|
|
51
|
+
case [req[:method], req[:path]]
|
|
52
|
+
in ["GET", "/"]
|
|
53
|
+
respond(client, type: "text/html; charset=utf-8", body: html_page)
|
|
54
|
+
in ["POST", "/ikura"]
|
|
55
|
+
coord_idx = parse_form(req[:body])["coord"]&.to_i || 0
|
|
56
|
+
x, y = COORDS[coord_idx] || [50, 50]
|
|
57
|
+
id = @ikura_count
|
|
58
|
+
@ikura_count += 1
|
|
59
|
+
|
|
60
|
+
jx = (x + rand(-8..8)).clamp(5, 95)
|
|
61
|
+
jy = (y + rand(-8..8)).clamp(5, 95)
|
|
62
|
+
|
|
63
|
+
respond(client,
|
|
64
|
+
type: "text/vnd.turbo-stream.html; charset=utf-8",
|
|
65
|
+
body: Ikura::Builder.append("ikura-layer",
|
|
66
|
+
"<li id='ikura_#{id}' class='ikura' style='left:#{jx}%;top:#{jy}%'></li>"))
|
|
67
|
+
|
|
68
|
+
puts " → append ikura_#{id} at (#{jx}%, #{jy}%)"
|
|
69
|
+
else
|
|
70
|
+
respond(client, status: "404 Not Found", type: "text/plain", body: "Not found")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def html_page
|
|
75
|
+
coords_html = COORDS.each_with_index.map { |(x, y), i|
|
|
76
|
+
"<div class='coord' style='left:#{x}%;top:#{y}%'></div>"
|
|
77
|
+
}.join("\n")
|
|
78
|
+
|
|
79
|
+
ERB.new(File.read(TEMPLATE_PATH)).result(binding)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parse_request(client)
|
|
83
|
+
line = client.gets
|
|
84
|
+
return nil unless line
|
|
85
|
+
|
|
86
|
+
method, path, _ = line.split(" ")
|
|
87
|
+
|
|
88
|
+
headers = {}
|
|
89
|
+
while (l = client.gets) && l.chomp != ""
|
|
90
|
+
key, val = l.split(": ", 2)
|
|
91
|
+
headers[key.strip.downcase] = val&.strip
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
body = nil
|
|
95
|
+
if (len = headers["content-length"]&.to_i)&.positive?
|
|
96
|
+
body = client.read(len)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
{ method:, path:, headers:, body: }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_form(body)
|
|
103
|
+
URI.decode_www_form(body.to_s).to_h
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def respond(client, status: "200 OK", type:, body:)
|
|
107
|
+
client.print "HTTP/1.1 #{status}\r\n"
|
|
108
|
+
client.print "Content-Type: #{type}\r\n"
|
|
109
|
+
client.print "Content-Length: #{body.bytesize}\r\n"
|
|
110
|
+
client.print "Access-Control-Allow-Origin: *\r\n"
|
|
111
|
+
client.print "Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS\r\n"
|
|
112
|
+
client.print "Access-Control-Allow-Headers: Content-Type, Accept\r\n"
|
|
113
|
+
client.print "Connection: close\r\n"
|
|
114
|
+
client.print "\r\n"
|
|
115
|
+
client.print body
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ja">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>🍣 Ikura</title>
|
|
6
|
+
<script type="module">
|
|
7
|
+
import { DefaultRubyVM } from "https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.8.1/dist/browser/+esm";
|
|
8
|
+
const res = await fetch("https://cdn.jsdelivr.net/npm/@ruby/4.0-wasm-wasi@2.8.1/dist/ruby+stdlib.wasm");
|
|
9
|
+
const { vm } = await DefaultRubyVM(await WebAssembly.compileStreaming(res));
|
|
10
|
+
window.rubyVM = vm;
|
|
11
|
+
vm.eval(`
|
|
12
|
+
require "js"
|
|
13
|
+
doc = JS.global[:document]
|
|
14
|
+
nodes = doc.call(:querySelectorAll, ".coord")
|
|
15
|
+
nodes[:length].to_i.times do |i|
|
|
16
|
+
node = nodes.call(:item, i)
|
|
17
|
+
node[:style][:opacity] = "1"
|
|
18
|
+
node.call(:addEventListener, "click", ->(event) {
|
|
19
|
+
opts = JS.eval("return {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
22
|
+
body: 'coord=#{i}'
|
|
23
|
+
}")
|
|
24
|
+
JS.global.fetch("/ikura", opts).call(:then, ->(response) {
|
|
25
|
+
response.text().call(:then, ->(text) {
|
|
26
|
+
doc = JS.global[:document]
|
|
27
|
+
div = doc.call(:createElement, "div")
|
|
28
|
+
div[:innerHTML] = text.to_s
|
|
29
|
+
div.call(:querySelectorAll, "turbo-stream")[:length].to_i.times do |j|
|
|
30
|
+
el = div.call(:querySelectorAll, "turbo-stream").call(:item, j)
|
|
31
|
+
action = el.call(:getAttribute, "action").to_s
|
|
32
|
+
target = el.call(:getAttribute, "target").to_s
|
|
33
|
+
content = el.call(:querySelector, "template")[:innerHTML].to_s rescue ""
|
|
34
|
+
target_node = doc.call(:getElementById, target)
|
|
35
|
+
case action
|
|
36
|
+
when "append" then target_node.call(:insertAdjacentHTML, "beforeend", content)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
})
|
|
41
|
+
nil
|
|
42
|
+
})
|
|
43
|
+
nil
|
|
44
|
+
})
|
|
45
|
+
end
|
|
46
|
+
`);
|
|
47
|
+
</script>
|
|
48
|
+
<style>
|
|
49
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
50
|
+
body { background: #ffffff; overflow: hidden; width: 100vw; height: 100vh; }
|
|
51
|
+
|
|
52
|
+
#board { position: relative; width: 100vw; height: 100vh; }
|
|
53
|
+
|
|
54
|
+
.gunkan {
|
|
55
|
+
position: absolute;
|
|
56
|
+
left: 50%; top: 50%;
|
|
57
|
+
transform: translate(-50%, -50%);
|
|
58
|
+
width: 140px;
|
|
59
|
+
height: 120px;
|
|
60
|
+
background: #1b2b1b;
|
|
61
|
+
border-radius: 50% / 20%;
|
|
62
|
+
box-shadow: inset 0 -10px 20px rgba(0,0,0,0.5);
|
|
63
|
+
z-index: 10;
|
|
64
|
+
pointer-events: none;
|
|
65
|
+
}
|
|
66
|
+
.gunkan-top {
|
|
67
|
+
position: absolute;
|
|
68
|
+
top: 2px; left: 8px;
|
|
69
|
+
width: 124px; height: 40px;
|
|
70
|
+
background: radial-gradient(ellipse at 50% 60%, #f8f4ec, #e8e0cc);
|
|
71
|
+
border-radius: 50%;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.coord {
|
|
75
|
+
position: absolute;
|
|
76
|
+
transform: translate(-50%, -50%);
|
|
77
|
+
width: 16px; height: 16px;
|
|
78
|
+
cursor: crosshair;
|
|
79
|
+
pointer-events: all;
|
|
80
|
+
opacity: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#ikura-layer {
|
|
84
|
+
position: absolute; inset: 0;
|
|
85
|
+
pointer-events: none; list-style: none;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.ikura {
|
|
89
|
+
position: absolute;
|
|
90
|
+
transform: translate(-50%, -50%);
|
|
91
|
+
width: 14px; height: 14px;
|
|
92
|
+
border-radius: 50%;
|
|
93
|
+
background: radial-gradient(circle at 35% 30%, #ffbb66, #cc2200);
|
|
94
|
+
border: 1px solid rgba(255,180,100,0.3);
|
|
95
|
+
animation: pop 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
96
|
+
}
|
|
97
|
+
@keyframes pop {
|
|
98
|
+
from { transform: translate(-50%, -50%) scale(0); opacity: 0; }
|
|
99
|
+
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
<div id="board">
|
|
105
|
+
<div class="gunkan">
|
|
106
|
+
<div class="gunkan-top">
|
|
107
|
+
<%= coords_html %>
|
|
108
|
+
<ul id="ikura-layer"></ul>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
</body>
|
|
114
|
+
</html>
|
data/lib/ikura.rb
ADDED
data/sig/ikura.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ikura
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- haruna-tsujita
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
description: Ikura is a tiny HTTP server that renders an interactive gunkan-maki sushi
|
|
27
|
+
in the browser. Click anywhere on the sushi to place ikura (salmon roe) using Ruby
|
|
28
|
+
running in WebAssembly.
|
|
29
|
+
email:
|
|
30
|
+
- snwxxx29@gmail.com
|
|
31
|
+
executables:
|
|
32
|
+
- ikura
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- ".ruby-version"
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- Gemfile
|
|
39
|
+
- README.md
|
|
40
|
+
- Rakefile
|
|
41
|
+
- exe/ikura
|
|
42
|
+
- lib/ikura.rb
|
|
43
|
+
- lib/ikura/builder.rb
|
|
44
|
+
- lib/ikura/server.rb
|
|
45
|
+
- lib/ikura/templates/ikura.html.erb
|
|
46
|
+
- lib/ikura/version.rb
|
|
47
|
+
- sig/ikura.rbs
|
|
48
|
+
homepage: https://github.com/haruna-tsujita/ikura
|
|
49
|
+
licenses:
|
|
50
|
+
- MIT
|
|
51
|
+
metadata:
|
|
52
|
+
allowed_push_host: https://rubygems.org
|
|
53
|
+
homepage_uri: https://github.com/haruna-tsujita/ikura
|
|
54
|
+
source_code_uri: https://github.com/haruna-tsujita/ikura/tree/master
|
|
55
|
+
changelog_uri: https://github.com/haruna-tsujita/ikura/blob/master/CHANGELOG.md
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: 4.0.2
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 4.0.6
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: An interactive gunkan-maki sushi toy server powered by Ruby Wasm
|
|
73
|
+
test_files: []
|