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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +602 -204
- data/js/cloudflare-kv.js +47 -0
- data/js/cloudflare-ratelimit.js +31 -0
- data/js/cloudflare-test-database.js +112 -0
- data/js/cloudflare-test-runner.js +76 -0
- data/js/cloudflare.js +25 -4
- data/js/fetch.js +113 -0
- data/js/jspi.js +32 -0
- data/js/jwt.js +5 -39
- data/js/runtime.js +42 -5
- data/lib/edgeway/application.rb +16 -7
- data/lib/edgeway/cache/limits.rb +8 -0
- data/lib/edgeway/cache/memory_store.rb +24 -0
- data/lib/edgeway/cache/null_store.rb +13 -0
- data/lib/edgeway/cache/store.rb +189 -0
- data/lib/edgeway/cache.rb +13 -0
- data/lib/edgeway/cli/build/cache_config.rb +108 -0
- data/lib/edgeway/cli/build/database_config.rb +4 -0
- data/lib/edgeway/cli/build/js_files.rb +30 -0
- data/lib/edgeway/cli/build.rb +17 -10
- data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
- data/lib/edgeway/cli/cloudflare_test.rb +120 -0
- data/lib/edgeway/cli/test.rb +25 -9
- data/lib/edgeway/cli/toolchain.rb +5 -0
- data/lib/edgeway/cli.rb +2 -0
- data/lib/edgeway/controller/base.rb +22 -3
- data/lib/edgeway/controller/caching.rb +140 -0
- data/lib/edgeway/controller/callbacks.rb +5 -1
- data/lib/edgeway/controller/rate_limiting.rb +145 -0
- data/lib/edgeway/db/query_stats.rb +2 -6
- data/lib/edgeway/errors.rb +21 -0
- data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +3 -3
- data/lib/edgeway/generators/app/templates/README.md.tt +3 -1
- data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
- data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +8 -2
- data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +34 -2
- data/lib/edgeway/http/client.rb +80 -0
- data/lib/edgeway/http/request.rb +5 -0
- data/lib/edgeway/http/stub_transport.rb +38 -0
- data/lib/edgeway/logger.rb +33 -5
- data/lib/edgeway/rate_limit/limiter.rb +9 -0
- data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
- data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
- data/lib/edgeway/rate_limit.rb +2 -0
- data/lib/edgeway/server.rb +3 -2
- data/lib/edgeway/service/cloudflare/d1.rb +6 -21
- data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
- data/lib/edgeway/service/cloudflare/kv.rb +45 -0
- data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
- data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
- data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
- data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
- data/lib/edgeway/service/fetch.rb +26 -0
- data/lib/edgeway/service/import_result.rb +24 -0
- data/lib/edgeway/test/sqlite_databases.rb +18 -0
- data/lib/edgeway/test.rb +72 -33
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway.rb +6 -0
- 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 <<
|
|
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
|
-
|
|
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
|
|
31
|
-
"
|
|
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
|
data/lib/edgeway/errors.rb
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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:
|
|
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-
|
|
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
|