edgeway 0.2.0 → 0.3.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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +602 -204
  4. data/js/cloudflare-kv.js +47 -0
  5. data/js/cloudflare-ratelimit.js +31 -0
  6. data/js/cloudflare-test-database.js +112 -0
  7. data/js/cloudflare-test-runner.js +76 -0
  8. data/js/cloudflare.js +25 -4
  9. data/js/fetch.js +113 -0
  10. data/js/jspi.js +32 -0
  11. data/js/jwt.js +5 -39
  12. data/js/runtime.js +42 -5
  13. data/lib/edgeway/application.rb +16 -7
  14. data/lib/edgeway/cache/limits.rb +8 -0
  15. data/lib/edgeway/cache/memory_store.rb +24 -0
  16. data/lib/edgeway/cache/null_store.rb +13 -0
  17. data/lib/edgeway/cache/store.rb +189 -0
  18. data/lib/edgeway/cache.rb +13 -0
  19. data/lib/edgeway/cli/build/cache_config.rb +108 -0
  20. data/lib/edgeway/cli/build/database_config.rb +4 -0
  21. data/lib/edgeway/cli/build/js_files.rb +30 -0
  22. data/lib/edgeway/cli/build.rb +17 -10
  23. data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
  24. data/lib/edgeway/cli/cloudflare_test.rb +120 -0
  25. data/lib/edgeway/cli/test.rb +25 -9
  26. data/lib/edgeway/cli/toolchain.rb +5 -0
  27. data/lib/edgeway/cli.rb +2 -0
  28. data/lib/edgeway/controller/base.rb +22 -3
  29. data/lib/edgeway/controller/caching.rb +140 -0
  30. data/lib/edgeway/controller/callbacks.rb +5 -1
  31. data/lib/edgeway/controller/rate_limiting.rb +145 -0
  32. data/lib/edgeway/db/query_stats.rb +2 -6
  33. data/lib/edgeway/errors.rb +21 -0
  34. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +3 -3
  35. data/lib/edgeway/generators/app/templates/README.md.tt +3 -1
  36. data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
  37. data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
  38. data/lib/edgeway/generators/app/templates/config/database.yml +8 -2
  39. data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
  40. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +34 -2
  41. data/lib/edgeway/http/client.rb +80 -0
  42. data/lib/edgeway/http/request.rb +5 -0
  43. data/lib/edgeway/http/stub_transport.rb +38 -0
  44. data/lib/edgeway/logger.rb +33 -5
  45. data/lib/edgeway/rate_limit/limiter.rb +9 -0
  46. data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
  47. data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
  48. data/lib/edgeway/rate_limit.rb +2 -0
  49. data/lib/edgeway/server.rb +3 -2
  50. data/lib/edgeway/service/cloudflare/d1.rb +6 -21
  51. data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
  52. data/lib/edgeway/service/cloudflare/kv.rb +45 -0
  53. data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
  54. data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
  55. data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
  56. data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
  57. data/lib/edgeway/service/fetch.rb +26 -0
  58. data/lib/edgeway/service/import_result.rb +24 -0
  59. data/lib/edgeway/test/sqlite_databases.rb +18 -0
  60. data/lib/edgeway/test.rb +72 -33
  61. data/lib/edgeway/version.rb +1 -1
  62. data/lib/edgeway.rb +6 -0
  63. metadata +34 -1
@@ -19,6 +19,11 @@ module Edgeway
19
19
  find_on_path("spinel") || mise_which("spinel")
20
20
  end
21
21
 
22
+ def self.wasi_sdk
23
+ dir = [ENV["WASI_SDK"], ENV["WASI_SDK_PATH"]].find { |value| !value.to_s.empty? } || "/opt/wasi-sdk"
24
+ File.directory?(dir) ? dir : nil
25
+ end
26
+
22
27
  def self.find_on_path(name)
23
28
  ENV["PATH"].to_s.split(File::PATH_SEPARATOR).map { |d| File.join(d, name) }
24
29
  .find { |f| File.file?(f) && File.executable?(f) }
data/lib/edgeway/cli.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "cli/toolchain"
9
9
  require_relative "cli/build"
10
10
  require_relative "cli/precheck"
11
11
  require_relative "cli/test"
12
+ require_relative "cli/cloudflare_test"
12
13
  require_relative "cli/routes"
13
14
  require_relative "cli/package_manager"
14
15
  require_relative "cli/generate"
@@ -24,6 +25,7 @@ module Edgeway
24
25
  compile the app to build/app.wasm (ENV: development);
25
26
  --release builds for deploying (no --incremental)
26
27
  test [ARGS] regenerate tmp/test_app.rb, run test/ with spinel_test-unit
28
+ (natively, or as wasm on the platform's local database, by database.yml's test:)
27
29
  generate, g KIND NAME generate a model (NAME field:type ...), a controller (NAME action ...)
28
30
  or a scaffold (NAME field:type ...)
29
31
  routes print the routes
@@ -5,16 +5,35 @@ module Edgeway
5
5
  include Rescuable
6
6
  include Rendering
7
7
  include Authentication
8
+ include Caching
9
+ include RateLimiting
8
10
 
9
11
  NON_ACTIONS = ["render", "head", "headers", "params", "request", "performed?", "to_response",
10
12
  "action?", "run_before_actions", "rescue_with_handler",
11
13
  "authenticate!", "current_claims", "authenticated?",
12
- "action_name", "action_name=", "raw"]
14
+ "action_name", "action_name=", "raw",
15
+ "expires_in", "no_store", "fresh_when", "stale?"]
13
16
 
14
17
  def self.before_action(name, only: [], except: [])
18
+ add_before_action(Callback.new(name, only.map(&:to_s), except.map(&:to_s)))
19
+ end
20
+
21
+ def self.rate_limit(binding_name, by: nil, name: nil, retry_after: nil, only: [], except: [])
22
+ add_before_action(RateLimiting::Rule.build(binding_name, by, name, retry_after, only, except))
23
+ (@own_non_actions ||= []) << by.to_s unless by.nil?
24
+ end
25
+
26
+ def self.own_non_actions = (@own_non_actions || [])
27
+
28
+ def self.non_action?(name)
29
+ lineage.any? { |c| c.own_non_actions.include?(name) }
30
+ end
31
+
32
+ def self.add_before_action(callback)
15
33
  @own_before_actions ||= []
16
- @own_before_actions << Callback.new(name, only.map(&:to_s), except.map(&:to_s))
34
+ @own_before_actions << callback
17
35
  end
36
+ private_class_method :add_before_action
18
37
 
19
38
  def self.own_before_actions = (@own_before_actions || [])
20
39
 
@@ -67,7 +86,7 @@ module Edgeway
67
86
 
68
87
  def action?(name)
69
88
  return false if name.start_with?("_view_") || name.start_with?("_render_")
70
- respond_to?(name.to_sym) && !NON_ACTIONS.include?(name)
89
+ respond_to?(name.to_sym) && !NON_ACTIONS.include?(name) && !self.class.non_action?(name)
71
90
  end
72
91
 
73
92
  def raw(text) = View::SafeHTML.new(text)
@@ -0,0 +1,140 @@
1
+ module Edgeway
2
+ module Controller
3
+ # HTTP caching helpers for actions:
4
+ #
5
+ # def show
6
+ # todo = Todo.find(params["id"])
7
+ # render json: todo if stale?(etag: todo, last_modified: todo.updated_at)
8
+ # end
9
+ #
10
+ # Only the validators an action names are used; nothing is derived from
11
+ # the response body.
12
+ module Caching
13
+ ETAG_LENGTH = 32
14
+
15
+ def expires_in(seconds, public: false, must_revalidate: false)
16
+ raise ArgumentError, "seconds must be a non-negative Integer" unless Caching.duration?(seconds)
17
+ value = "max-age=#{seconds}, #{public ? "public" : "private"}"
18
+ value += ", must-revalidate" if must_revalidate
19
+ @headers["Cache-Control"] = value
20
+ nil
21
+ end
22
+
23
+ def no_store
24
+ @headers["Cache-Control"] = "no-store"
25
+ nil
26
+ end
27
+
28
+ # Returns true when it answered 304 (the action is done), false
29
+ # otherwise.
30
+ def fresh_when(etag: nil, last_modified: nil, public: false)
31
+ etag_value = etag.nil? ? nil : Caching.etag_for(etag)
32
+ modified_time = Caching.time_for(last_modified)
33
+ @headers["ETag"] = etag_value unless etag_value.nil?
34
+ @headers["Last-Modified"] = modified_time.httpdate unless modified_time.nil?
35
+ set_default_cache_control(public)
36
+ return false unless cacheable_request? && request_fresh?(etag_value, modified_time)
37
+ remove_representation_headers
38
+ head :not_modified
39
+ true
40
+ end
41
+
42
+ # fresh_when, answering true when the action should go on to render.
43
+ def stale?(etag: nil, last_modified: nil, public: false)
44
+ !fresh_when(etag: etag, last_modified: last_modified, public: public)
45
+ end
46
+
47
+ # Integer() gives Spinel a type for a parameter no call site types.
48
+ def self.duration?(seconds)
49
+ seconds.is_a?(Integer) && Integer(seconds) >= 0
50
+ end
51
+
52
+ # Weak, because a proxy may recompress the body and weaken a strong
53
+ # validator on the way.
54
+ def self.etag_for(source)
55
+ digest = Digest::SHA256.hexdigest(etag_source(source))
56
+ "W/\"#{digest[0, ETAG_LENGTH]}\""
57
+ end
58
+
59
+ def self.etag_source(source)
60
+ if source.is_a?(Model::Base)
61
+ model_source(source)
62
+ elsif source.is_a?(Model::Relation)
63
+ list_source(source.to_a)
64
+ elsif source.is_a?(Array)
65
+ list_source(source)
66
+ else
67
+ source.to_s
68
+ end
69
+ end
70
+
71
+ def self.model_source(model)
72
+ updated_at = model.as_json["updated_at"].to_s
73
+ raise ArgumentError, "#{model.class.name} has no updated_at (add timestamps)" if updated_at.empty?
74
+ "#{model.class.name}/#{model.id}/#{updated_at}"
75
+ end
76
+
77
+ def self.list_source(items)
78
+ "[" + items.map { |item| etag_source(item) }.join(",") + "]"
79
+ end
80
+
81
+ def self.time_for(value)
82
+ return nil if value.nil?
83
+ return value.getutc if value.is_a?(Time)
84
+ raise ArgumentError, "last_modified must be a Time or an ISO 8601 String" unless value.is_a?(String)
85
+ begin
86
+ Time.iso8601(value).utc
87
+ rescue ArgumentError
88
+ raise ArgumentError, "last_modified is not an ISO 8601 String: #{value.inspect}"
89
+ end
90
+ end
91
+
92
+ # The validators match the ones the client sent (RFC 9110 13.1.2,
93
+ # 13.1.3): If-None-Match decides when it is present, and
94
+ # If-Modified-Since is then ignored.
95
+ def self.fresh?(none_match, modified_since, etag, last_modified)
96
+ return etag_listed?(none_match, etag) unless none_match.empty?
97
+ return false if modified_since.empty? || last_modified.nil?
98
+ since = parse_http_date(modified_since)
99
+ !since.nil? && last_modified.to_i <= since.to_i
100
+ end
101
+
102
+ def self.etag_listed?(header, etag)
103
+ return false if etag.nil?
104
+ candidates = header.split(",").map { |candidate| candidate.strip }
105
+ return true if candidates.include?("*")
106
+ candidates.any? { |candidate| candidate.delete_prefix("W/") == etag.delete_prefix("W/") }
107
+ end
108
+
109
+ def self.parse_http_date(text)
110
+ Time.httpdate(text).utc
111
+ rescue ArgumentError
112
+ nil
113
+ end
114
+
115
+ private
116
+
117
+ def set_default_cache_control(public)
118
+ return if header_set?("Cache-Control")
119
+ @headers["Cache-Control"] = "max-age=0, #{public ? "public" : "private"}, must-revalidate"
120
+ end
121
+
122
+ def header_set?(name)
123
+ @headers.keys.any? { |key| key.downcase == name.downcase }
124
+ end
125
+
126
+ def remove_representation_headers
127
+ @headers.delete("Content-Type")
128
+ @headers.delete("X-Content-Type-Options")
129
+ end
130
+
131
+ def cacheable_request?
132
+ @request.method == "GET" || @request.method == "HEAD"
133
+ end
134
+
135
+ def request_fresh?(etag_value, modified_time)
136
+ Caching.fresh?(@request.header("If-None-Match"), @request.header("If-Modified-Since"), etag_value, modified_time)
137
+ end
138
+ end
139
+ end
140
+ end
@@ -9,6 +9,10 @@ module Edgeway
9
9
  @except = except
10
10
  end
11
11
 
12
+ def run(controller)
13
+ controller.send(@name)
14
+ end
15
+
12
16
  def applies?(action)
13
17
  return false if @except.include?(action)
14
18
  @only.empty? || @only.include?(action)
@@ -19,7 +23,7 @@ module Edgeway
19
23
  def run_before_actions(action)
20
24
  self.class.all_before_actions.each do |callback|
21
25
  next unless callback.applies?(action)
22
- send(callback.name)
26
+ callback.run(self)
23
27
  return false if performed?
24
28
  end
25
29
  true
@@ -0,0 +1,145 @@
1
+ module Edgeway
2
+ module Controller
3
+ @rate_limiter = RateLimit::NullLimiter.new
4
+
5
+ def self.rate_limiter = @rate_limiter
6
+
7
+ def self.rate_limiter=(limiter)
8
+ @rate_limiter = limiter
9
+ end
10
+
11
+ # Rate limiting through a limiter binding:
12
+ #
13
+ # class PostsController < ApplicationController
14
+ # rate_limit "WRITE_LIMITER", only: [:create], retry_after: 10
15
+ # end
16
+ #
17
+ # It is a before_action. Over the limit it raises TooManyRequests (429).
18
+ # The limit and the period are set on the binding, not here. A by: method is
19
+ # never a route's action (Base.rate_limit adds it to the non-actions).
20
+ module RateLimiting
21
+ class Rule < Callback
22
+ attr_reader :binding_name, :by, :scope_name, :retry_after
23
+
24
+ def self.build(binding_name, by, name, retry_after, only, except)
25
+ raise ArgumentError, "the limiter binding name must be a non-empty String" unless binding_name.is_a?(String) && !binding_name.empty?
26
+ raise ArgumentError, "by: must be a Symbol" unless by.nil? || by.is_a?(Symbol)
27
+ raise ArgumentError, "name: must be a String" unless name.nil? || name.is_a?(String)
28
+ raise ArgumentError, "retry_after: must be a positive Integer" unless retry_after.nil? || RateLimiting.positive_integer?(retry_after)
29
+ new(binding_name, by, name, retry_after.nil? ? 0 : retry_after, only.map(&:to_s), except.map(&:to_s))
30
+ end
31
+
32
+ def initialize(binding_name, by, name, retry_after, only, except)
33
+ super("rate_limit", only, except)
34
+ @binding_name = binding_name
35
+ @by = by
36
+ @scope_name = name
37
+ @retry_after = retry_after
38
+ end
39
+
40
+ def run(controller)
41
+ controller.send(:enforce_rate_limit, self)
42
+ end
43
+ end
44
+
45
+ IPV6_GROUPS = 8
46
+ IPV6_PREFIX_GROUPS = 4
47
+ IPV4_OCTETS = 4
48
+ IPV4_MAPPED_ZERO_GROUPS = 5
49
+ IPV4_MAPPED_MARKER_GROUP = 5
50
+ IPV4_HIGH_GROUP = 6
51
+ IPV4_LOW_GROUP = 7
52
+ HEX_DIGITS = "0123456789abcdef"
53
+
54
+ # An IPv6 client usually has a whole /64, so its address is keyed by
55
+ # that prefix; an IPv4 address (or one that does not parse) is kept.
56
+ def self.address_key(address)
57
+ return address unless address.include?(":")
58
+
59
+ text = address.downcase.split("%")[0].to_s
60
+ groups = ipv6_groups(embedded_ipv4_as_hex(text))
61
+ return address if groups.nil?
62
+
63
+ ipv4_mapped?(groups) ? ipv4_from_groups(groups) : groups[0, IPV6_PREFIX_GROUPS].join(":") + "::/64"
64
+ end
65
+
66
+ # "::ffff:192.0.2.1" -> "::ffff:c000:201", so the dotted tail parses as two groups.
67
+ def self.embedded_ipv4_as_hex(text)
68
+ colon = text.rindex(":").to_i
69
+ octets = text[colon + 1, text.length].to_s.split(".", -1)
70
+ return text unless octets.length == IPV4_OCTETS && octets.all? { |octet| decimal_octet?(octet) }
71
+
72
+ values = octets.map { |octet| octet.to_i }
73
+ high = (values[0] * 256 + values[1]).to_s(16)
74
+ low = (values[2] * 256 + values[3]).to_s(16)
75
+ text[0, colon + 1].to_s + high + ":" + low
76
+ end
77
+
78
+ def self.decimal_octet?(octet)
79
+ octet.length >= 1 && octet.length <= 3 && octet.each_char.all? { |digit| "0123456789".include?(digit) } && octet.to_i <= 255
80
+ end
81
+
82
+ def self.ipv4_mapped?(groups)
83
+ groups[0, IPV4_MAPPED_ZERO_GROUPS].all? { |group| group == "0" } && groups[IPV4_MAPPED_MARKER_GROUP] == "ffff"
84
+ end
85
+
86
+ def self.ipv4_from_groups(groups)
87
+ high = groups[IPV4_HIGH_GROUP].to_i(16)
88
+ low = groups[IPV4_LOW_GROUP].to_i(16)
89
+ [high / 256, high % 256, low / 256, low % 256].join(".")
90
+ end
91
+
92
+ def self.ipv6_groups(text)
93
+ halves = text.split("::", -1)
94
+ return nil if halves.empty? || halves.length > 2
95
+
96
+ head = group_list(halves[0])
97
+ tail = halves.length == 2 ? group_list(halves[1]) : []
98
+ return nil if head.nil? || tail.nil?
99
+
100
+ filler = IPV6_GROUPS - head.length - tail.length
101
+ return nil if halves.length == 1 ? filler != 0 : filler < 1
102
+
103
+ head + Array.new(filler, "0") + tail
104
+ end
105
+
106
+ def self.group_list(text)
107
+ return [] if text.empty?
108
+
109
+ groups = text.split(":", -1).map { |group| normalized_group(group) }
110
+ groups.include?("") ? nil : groups
111
+ end
112
+
113
+ def self.normalized_group(group)
114
+ return "" if group.empty? || group.length > 4 || !group.each_char.all? { |digit| HEX_DIGITS.include?(digit) }
115
+
116
+ group.to_i(16).to_s(16)
117
+ end
118
+
119
+ # Integer() gives Spinel a type for a parameter no call site types.
120
+ def self.positive_integer?(value)
121
+ value.is_a?(Integer) && Integer(value) > 0
122
+ end
123
+
124
+ private
125
+
126
+ def enforce_rate_limit(rule)
127
+ return unless Edgeway::Controller.rate_limiter.exceeded?(rule.binding_name, rate_limit_key_for(rule))
128
+
129
+ raise TooManyRequests.new("rate limit exceeded (#{rule.binding_name})", rule.retry_after)
130
+ end
131
+
132
+ def rate_limit_key_for(rule)
133
+ scope = rule.scope_name.nil? ? self.class.name : rule.scope_name
134
+ "#{scope}:#{rule.by.nil? ? default_rate_limit_key : send(rule.by).to_s}"
135
+ end
136
+
137
+ def default_rate_limit_key
138
+ subject = authenticated? ? current_claims["sub"].to_s : ""
139
+ return "user:#{subject}" unless subject.empty?
140
+
141
+ "ip:#{RateLimiting.address_key(request.remote_addr)}"
142
+ end
143
+ end
144
+ end
145
+ end
@@ -27,16 +27,12 @@ module Edgeway
27
27
  @queries = capped_sum(@queries, 1)
28
28
  end
29
29
 
30
- def log_text
31
- "db=#{@queries} rows_read=#{count_text(@rows_read)} rows_written=#{count_text(@rows_written)} db_ms=#{format("%.2f", @duration_ms)}"
30
+ def log_fields
31
+ { "queries" => @queries, "rows_read" => @rows_read, "rows_written" => @rows_written, "db_ms" => @duration_ms.round(2) }
32
32
  end
33
33
 
34
34
  private
35
35
 
36
- def count_text(count)
37
- count == MAX_COUNT ? "#{count}+" : count.to_s
38
- end
39
-
40
36
  def capped_sum(total, count)
41
37
  return MAX_COUNT if count.to_i > MAX_COUNT - total
42
38
  total + count.to_i
@@ -28,6 +28,17 @@ module Edgeway
28
28
  # ({"error":"invalid token"}) and the challenge no description.
29
29
  class InvalidToken < Unauthorized; end
30
30
 
31
+ # A request over a rate limit: 429 with a fixed body. retry_after is the
32
+ # seconds for a Retry-After header, 0 for none.
33
+ class TooManyRequests < Error
34
+ attr_reader :retry_after
35
+
36
+ def initialize(message = "rate limit exceeded", retry_after = 0)
37
+ super(message)
38
+ @retry_after = retry_after.is_a?(Integer) && Integer(retry_after) > 0 ? retry_after : 0
39
+ end
40
+ end
41
+
31
42
  # A record that doesn't exist: 404.
32
43
  class RecordNotFound < Error; end
33
44
 
@@ -71,6 +82,16 @@ module Edgeway
71
82
  # been applied, so only an idempotent request is safe to retry.
72
83
  class DatabaseUnavailable < DatabaseError; end
73
84
 
85
+ module HTTP
86
+ # An outgoing request that failed (a network error, a URL that is refused,
87
+ # a response over the size limit): 500. The message goes to the log only.
88
+ class Error < Edgeway::Error; end
89
+
90
+ # An outgoing request that did not finish within its timeout: 500. The
91
+ # message goes to the log only.
92
+ class Timeout < Error; end
93
+ end
94
+
74
95
  # A record that failed its validations: 422 with {"errors": [...]}.
75
96
  class RecordInvalid < Error
76
97
  attr_reader :errors
@@ -20,9 +20,6 @@ jobs:
20
20
  - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
21
21
  with:
22
22
  persist-credentials: false
23
- # `edgeway test` links libsqlite3.
24
- - name: Install libsqlite3-dev
25
- run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
26
23
  # The Ruby in .ruby-version.
27
24
  - name: Set up Ruby
28
25
  uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
@@ -32,6 +29,9 @@ jobs:
32
29
  # Node.js, and <%= PackageManager.name %>.
33
30
  - name: Install the tools
34
31
  uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0
32
+ # `edgeway test` runs the tests on the local D1 of wrangler.
33
+ - name: Install wrangler
34
+ run: <%= PackageManager.install %>
35
35
  - name: Run the tests
36
36
  run: bin/test
37
37
  - name: Build
@@ -17,9 +17,11 @@ bundle exec edgeway routes
17
17
 
18
18
  `bin/deploy` builds with `--release`: one unit, without Spinel's `--incremental` (cached C units, no inlining across units), for deploying.
19
19
 
20
+ Files in `public/` are served by Cloudflare before the app, for every method on their path (see Edgeway's README, "Static assets").
21
+
20
22
  Without mise, set `SPINEL` to the Spinel compiler and `WASI_SDK` to the WASI SDK.
21
23
 
22
- Tests (`test/`) run with `bin/test [FILE...]` (all of `test/` when no file is given; it runs `bundle exec edgeway test`). Always use it rather than `spinel_test-unit` directly: it rewrites `tmp/test_app.rb` (git-ignored) from `app/` on every run, so new files are picked up. Each test gets a fresh in-memory SQLite database with `db/migrations/*.sql` applied, so running the tests requires SQLite (sqlite3): its development library has to be installed to link (`apt install libsqlite3-dev` on Debian/Ubuntu, `dnf install sqlite-devel` on Fedora; macOS has it).
24
+ Tests (`test/`) run with `bin/test [FILE...]` (all of `test/` when no file is given; it runs `bundle exec edgeway test`). Always use it rather than `spinel_test-unit` directly: it rewrites `tmp/test_app.rb` (git-ignored) from `app/` on every run, so new files are picked up. The tests are compiled to wasm like the app and run by node on wrangler's local D1 (in memory, so `.wrangler/state` is not touched); each test gets an emptied database with `db/migrations/*.sql` applied. This needs the tools of `bin/setup` (Spinel, the WASI SDK, Node.js and wrangler).
23
25
 
24
26
  `.github/workflows/ci.yml` runs `bin/test` and `bin/build --release` on GitHub Actions for pushes to main and pull requests.
25
27
 
@@ -3,8 +3,7 @@
3
3
  set -euo pipefail
4
4
  cd "$(dirname "$0")/.."
5
5
 
6
- # Spinel and the WASI SDK, from mise.toml. `edgeway test` also links libsqlite3
7
- # (libsqlite3-dev on Debian/Ubuntu; part of macOS).
6
+ # Spinel, the WASI SDK and Node.js, from mise.toml.
8
7
  if command -v mise >/dev/null 2>&1; then
9
8
  mise trust --quiet
10
9
  mise install
@@ -15,7 +14,7 @@ fi
15
14
 
16
15
  bundle install
17
16
 
18
- # wrangler, from package.json.
17
+ # wrangler, from package.json (`edgeway test` runs the tests on its local D1).
19
18
  if ! command -v <%= PackageManager.name %> >/dev/null 2>&1; then
20
19
  echo "bin/setup: <%= PackageManager.name %> is not installed; install it to get wrangler." >&2
21
20
  exit 1
@@ -0,0 +1,22 @@
1
+ # The cache store of each environment (edgeway build --env ENV), Edgeway.cache
2
+ # (see the README's "Caching"). Without this file, or a section for the
3
+ # environment, the store is null: it keeps nothing, so Edgeway.cache.fetch
4
+ # always runs its block.
5
+ #
6
+ # store: kv, memory or null. kv keeps entries in a Cloudflare KV
7
+ # namespace; memory keeps them for the one request only.
8
+ # binding: the name of a [[kv_namespaces]] binding in wrangler.toml (kv).
9
+ # namespace: a prefix for every key (optional).
10
+ # expires_in: the seconds an entry lives when the call gives none (optional).
11
+ #
12
+ # `edgeway test` always uses a null store, whatever this file says.
13
+ # production:
14
+ # store: kv
15
+ # binding: CACHE
16
+ # namespace: myapp
17
+ # expires_in: 3600
18
+ development:
19
+ store: null
20
+
21
+ production:
22
+ store: null
@@ -1,7 +1,12 @@
1
1
  # The database of each environment (edgeway build --env ENV), compiled into
2
2
  # the wasm. adapter: d1 uses the D1 binding named by binding:, which must
3
3
  # match a binding in wrangler.toml (wrangler.toml says which D1 database it
4
- # is); memory is for tests.
4
+ # is); memory is a database that stores nothing (as the test: adapter it
5
+ # means native tests on SQLite instead; see the README's "Testing an app").
6
+ #
7
+ # `edgeway test` runs the tests on a local D1 (in memory, like `wrangler dev`)
8
+ # with adapter: d1, each test starting from the migrations (db/migrations and
9
+ # db/<name>/migrations).
5
10
  #
6
11
  # Another database goes under databases:, which a model picks with
7
12
  # `self.database = :analytics` (see the README's "Multiple databases").
@@ -28,4 +33,5 @@ production:
28
33
  binding: DB
29
34
 
30
35
  test:
31
- adapter: memory
36
+ adapter: d1
37
+ binding: DB
@@ -0,0 +1 @@
1
+ # Files in public/ not to upload as static assets (.gitignore syntax).
@@ -2,6 +2,11 @@ name = "<%= @name %>"
2
2
  main = "src/worker.js"
3
3
  compatibility_date = "2026-10-01"
4
4
 
5
+ # Workers Logs records what the app logs (Edgeway.logger), one entry per line,
6
+ # with its fields indexed (see the README's "Logging").
7
+ [observability]
8
+ enabled = true
9
+
5
10
  # CORS, for a frontend on another origin (see the README's "CORS"): the
6
11
  # origins allowed, comma-separated and compared exactly (no trailing slash).
7
12
  # Unset, CORS is off. CORS_HEADERS defaults to "Authorization, Content-Type",
@@ -11,6 +16,14 @@ compatibility_date = "2026-10-01"
11
16
  # [vars]
12
17
  # CORS_ORIGINS = "http://localhost:3000, https://app.example"
13
18
 
19
+ # Files in public/ are served as is, without starting the app, so they are
20
+ # always public: `before_action :authenticate!` never sees them. A file takes
21
+ # its path from the app for every method (other than GET/HEAD it is 405):
22
+ # public/index.html answers `/`, public/todos.html `/todos`. Anything else goes
23
+ # to the app. For an SPA, see Edgeway's README, "Static assets".
24
+ [assets]
25
+ directory = "./public"
26
+
14
27
  # Simulated locally by `wrangler dev`. Create tables with
15
28
  # `<%= PackageManager.exec %> wrangler d1 migrations apply <%= @name %>-db --local`.
16
29
  # database_id is a placeholder (all zeros): that is enough for --local and
@@ -22,9 +35,28 @@ database_name = "<%= @name %>-db"
22
35
  database_id = "00000000-0000-0000-0000-000000000000"
23
36
  migrations_dir = "db/migrations"
24
37
 
25
- # A second database (config/database.yml's databases:) is another binding:
38
+ # A second database (config/database.yml's databases:) is another binding,
39
+ # with an id of its own: the same id is the same local database:
26
40
  # [[d1_databases]]
27
41
  # binding = "ANALYTICS"
28
42
  # database_name = "<%= @name %>-analytics"
29
- # database_id = "00000000-0000-0000-0000-000000000000"
43
+ # database_id = "00000000-0000-0000-0000-000000000001"
30
44
  # migrations_dir = "db/analytics/migrations"
45
+
46
+ # The cache (config/cache.yml, `store: kv`; see Edgeway's README, "Caching") keeps
47
+ # its entries in a KV namespace. Simulated locally by `wrangler dev`. Before a
48
+ # deploy, create the namespace (`<%= PackageManager.exec %> wrangler kv namespace create CACHE`)
49
+ # and put the id it prints here.
50
+ # [[kv_namespaces]]
51
+ # binding = "CACHE"
52
+ # id = "00000000000000000000000000000000"
53
+
54
+ # Rate limiting (`rate_limit "API_LIMITER"` in a controller; see Edgeway's README,
55
+ # "Rate limiting"). The period is 10 or 60 seconds; namespace_id is a positive
56
+ # integer, as a string, that is unique in your account. `wrangler dev` enforces it.
57
+ # [[ratelimits]]
58
+ # name = "API_LIMITER"
59
+ # namespace_id = "1001"
60
+ # [ratelimits.simple]
61
+ # limit = 100
62
+ # period = 60