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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require_relative "store"
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module Cache
|
|
5
|
+
class MemoryStore < Store
|
|
6
|
+
def initialize(namespace: nil, expires_in: nil)
|
|
7
|
+
super(namespace: namespace, expires_in: expires_in)
|
|
8
|
+
@texts = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def read_text(key) = @texts[key]
|
|
12
|
+
|
|
13
|
+
def write_text(key, text, expires_in_seconds)
|
|
14
|
+
@texts[key] = text
|
|
15
|
+
true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def delete_text(key)
|
|
19
|
+
@texts.delete(key)
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
require_relative "limits"
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module Cache
|
|
5
|
+
MAX_KEY_BYTES = 512
|
|
6
|
+
KEY_DIGEST_LENGTH = 64
|
|
7
|
+
|
|
8
|
+
def self.utf8?(text) = text.dup.force_encoding("UTF-8").valid_encoding?
|
|
9
|
+
|
|
10
|
+
class Store
|
|
11
|
+
def initialize(namespace: nil, expires_in: nil)
|
|
12
|
+
@namespace = namespace.nil? ? "" : namespace.dup.force_encoding("UTF-8")
|
|
13
|
+
@default_expires_in = expires_in.nil? ? 0 : checked_expires_in(expires_in)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def read(key)
|
|
17
|
+
entry = lookup(key)
|
|
18
|
+
entry.nil? ? nil : entry["v"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def exist?(key) = !lookup(key).nil?
|
|
22
|
+
|
|
23
|
+
def write(key, value, expires_in: nil)
|
|
24
|
+
seconds = expires_in_seconds(expires_in)
|
|
25
|
+
check_cacheable(value)
|
|
26
|
+
write_text(expand_key(key), envelope(value, seconds), seconds)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fetch(key, expires_in: nil, force: false, skip_nil: false)
|
|
30
|
+
unless block_given?
|
|
31
|
+
raise ArgumentError, "missing block" if force
|
|
32
|
+
|
|
33
|
+
return read(key)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless force
|
|
37
|
+
entry = lookup(key)
|
|
38
|
+
return entry["v"] unless entry.nil?
|
|
39
|
+
end
|
|
40
|
+
value = yield
|
|
41
|
+
write(key, value, expires_in: expires_in) unless skip_nil && value.nil?
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete(key) = delete_text(expand_key(key))
|
|
46
|
+
|
|
47
|
+
def read_text(key)
|
|
48
|
+
raise Edgeway::Error, "#{self.class.name} does not read"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write_text(key, text, expires_in_seconds)
|
|
52
|
+
raise Edgeway::Error, "#{self.class.name} does not write"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def delete_text(key)
|
|
56
|
+
raise Edgeway::Error, "#{self.class.name} does not delete"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def current_time = Time.now.to_i
|
|
62
|
+
|
|
63
|
+
def lookup(key)
|
|
64
|
+
text = read_text(expand_key(key))
|
|
65
|
+
return nil if text.nil?
|
|
66
|
+
|
|
67
|
+
entry = parse_entry(text)
|
|
68
|
+
return nil if entry.nil? || expired?(entry)
|
|
69
|
+
|
|
70
|
+
entry
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_entry(text)
|
|
74
|
+
entry = JSON.parse(text)
|
|
75
|
+
entry.is_a?(Hash) && entry.key?("v") ? entry : nil
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def expired?(entry)
|
|
81
|
+
entry["e"].is_a?(Integer) && Integer(entry["e"]) <= current_time
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def envelope(value, seconds)
|
|
85
|
+
entry = { "v" => value }
|
|
86
|
+
entry["e"] = current_time + seconds if seconds > 0
|
|
87
|
+
JSON.generate(entry)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def expires_in_seconds(expires_in)
|
|
91
|
+
expires_in.nil? ? @default_expires_in : checked_expires_in(expires_in)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def checked_expires_in(seconds)
|
|
95
|
+
unless seconds.is_a?(Integer) && Integer(seconds) > 0 && Integer(seconds) <= MAX_EXPIRES_IN
|
|
96
|
+
raise ArgumentError, "expires_in must be a whole number of seconds, 1 to #{MAX_EXPIRES_IN}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
seconds
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check_cacheable(value)
|
|
103
|
+
if value.is_a?(String)
|
|
104
|
+
check_text(value)
|
|
105
|
+
elsif value.is_a?(Array)
|
|
106
|
+
value.each { |item| check_cacheable(item) }
|
|
107
|
+
elsif value.is_a?(Hash)
|
|
108
|
+
check_hash(value)
|
|
109
|
+
elsif value.is_a?(Float)
|
|
110
|
+
raise ArgumentError, "a Float to cache must be finite" unless Float(value).finite?
|
|
111
|
+
elsif !json_scalar?(value)
|
|
112
|
+
raise ArgumentError, "#{value.class.name} can't be cached (String, Integer, Float, true, false, nil, Array and Hash can)"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def check_text(text)
|
|
117
|
+
raise ArgumentError, "a String to cache must be valid UTF-8 (a binary String is not)" unless Cache.utf8?(text)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def check_hash(hash)
|
|
121
|
+
names = {}
|
|
122
|
+
hash.each do |name, item|
|
|
123
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
124
|
+
raise ArgumentError, "a Hash to cache needs String or Symbol keys (got #{name.class.name})"
|
|
125
|
+
end
|
|
126
|
+
raise ArgumentError, "a Hash to cache has the same key twice (as a String and a Symbol)" if names.key?(name.to_s)
|
|
127
|
+
|
|
128
|
+
names[name.to_s] = true
|
|
129
|
+
check_cacheable(name.to_s)
|
|
130
|
+
check_cacheable(item)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def json_scalar?(value) = value.nil? || value == true || value == false || value.is_a?(Integer)
|
|
135
|
+
|
|
136
|
+
def expand_key(key)
|
|
137
|
+
text = key_text(key)
|
|
138
|
+
raise ArgumentError, "cache key can't be blank" if text.empty?
|
|
139
|
+
|
|
140
|
+
text = "#{@namespace}:#{text}" unless @namespace.empty?
|
|
141
|
+
check_key_text(text)
|
|
142
|
+
text.bytesize > MAX_KEY_BYTES ? shortened(text) : text
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def check_key_text(text)
|
|
146
|
+
raise ArgumentError, "cache key must be valid UTF-8" unless Cache.utf8?(text)
|
|
147
|
+
raise ArgumentError, "cache key can't contain a NUL byte" if text.include?("\0")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def key_text(key)
|
|
151
|
+
if key.is_a?(Edgeway::Model::Base)
|
|
152
|
+
model_key(key)
|
|
153
|
+
elsif key.is_a?(Array)
|
|
154
|
+
key.map { |part| key_text(part) }.join("/")
|
|
155
|
+
elsif key.is_a?(String)
|
|
156
|
+
utf8_key_part(key)
|
|
157
|
+
elsif key.is_a?(Symbol) || key.is_a?(Integer)
|
|
158
|
+
key.to_s
|
|
159
|
+
else
|
|
160
|
+
raise ArgumentError, "a cache key is a String, Symbol, Integer, model or an Array of them (got #{key.class.name})"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def utf8_key_part(part)
|
|
165
|
+
raise ArgumentError, "cache key must be valid UTF-8" unless Cache.utf8?(part)
|
|
166
|
+
|
|
167
|
+
part.dup.force_encoding("UTF-8")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def model_key(model)
|
|
171
|
+
updated_at = model.as_json["updated_at"].to_s
|
|
172
|
+
raise ArgumentError, "#{model.class.name} has no updated_at (add timestamps)" if updated_at.empty?
|
|
173
|
+
|
|
174
|
+
"#{model.class.table_name}/#{model.id}-#{updated_at}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def shortened(text)
|
|
178
|
+
prefix_bytes = MAX_KEY_BYTES - KEY_DIGEST_LENGTH - 1
|
|
179
|
+
prefix = +""
|
|
180
|
+
text.each_char do |character|
|
|
181
|
+
break if prefix.bytesize + character.bytesize > prefix_bytes
|
|
182
|
+
|
|
183
|
+
prefix << character
|
|
184
|
+
end
|
|
185
|
+
"#{prefix}:#{Digest::SHA256.hexdigest(text)}"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../cache/limits"
|
|
4
|
+
|
|
5
|
+
module Edgeway
|
|
6
|
+
module CLI
|
|
7
|
+
class Build
|
|
8
|
+
class CacheConfig
|
|
9
|
+
STORES = {
|
|
10
|
+
"kv" => "edgeway/service/cloudflare/kv",
|
|
11
|
+
"memory" => nil,
|
|
12
|
+
"null" => nil
|
|
13
|
+
}.freeze
|
|
14
|
+
KEYS = %w[store binding namespace expires_in].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :store, :binding_name, :namespace, :expires_in
|
|
17
|
+
|
|
18
|
+
def self.load(root, env)
|
|
19
|
+
path = File.join(root, "config", "cache.yml")
|
|
20
|
+
return new("null") unless File.exist?(path)
|
|
21
|
+
|
|
22
|
+
sections = YAML.safe_load_file(path) || {}
|
|
23
|
+
raise Error, "config/cache.yml must be a Hash of environments" unless sections.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
section = sections[env]
|
|
26
|
+
return new("null") if section.nil?
|
|
27
|
+
raise Error, "config/cache.yml: #{env} must be a Hash" unless section.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
from_section(section, env)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.from_section(section, env)
|
|
33
|
+
unknown = section.keys.map(&:to_s) - KEYS
|
|
34
|
+
raise Error, "config/cache.yml: unknown #{unknown.join(", ")} in #{env} (one of #{KEYS.join(", ")})" unless unknown.empty?
|
|
35
|
+
|
|
36
|
+
new(store_name(section), binding_name: section["binding"], namespace: section["namespace"],
|
|
37
|
+
expires_in: section["expires_in"], env: env)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.store_name(section)
|
|
41
|
+
section.key?("store") && section["store"].nil? ? "null" : section["store"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(store, binding_name: nil, namespace: nil, expires_in: nil, env: "development")
|
|
45
|
+
@env = env
|
|
46
|
+
@store = checked_store(store)
|
|
47
|
+
@binding_name = checked_binding(binding_name)
|
|
48
|
+
@namespace = checked_namespace(namespace)
|
|
49
|
+
@expires_in = checked_expires_in(expires_in)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def backend_file = STORES.fetch(@store)
|
|
53
|
+
|
|
54
|
+
def store_expression
|
|
55
|
+
case @store
|
|
56
|
+
when "kv" then kv_expression
|
|
57
|
+
when "memory" then "Edgeway::Cache::MemoryStore.new(#{store_options})"
|
|
58
|
+
else "Edgeway::Cache::NullStore.new"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def store_options = "namespace: #{@namespace.inspect}, expires_in: #{@expires_in.inspect}"
|
|
65
|
+
|
|
66
|
+
def kv_expression
|
|
67
|
+
"Edgeway::Cloudflare::KVStore.new(binding: #{@binding_name.inspect}, #{store_options}, " \
|
|
68
|
+
"transport: Edgeway::Cloudflare::KVImportTransport.new)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def checked_store(store)
|
|
72
|
+
return store if STORES.key?(store)
|
|
73
|
+
|
|
74
|
+
raise Error, "config/cache.yml: store in #{@env} must be one of #{STORES.keys.join(", ")} (got #{store.inspect})"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def checked_binding(binding_name)
|
|
78
|
+
return checked_unused_binding(binding_name) unless @store == "kv"
|
|
79
|
+
raise Error, "config/cache.yml: binding: in #{@env} must be a String" unless binding_name.nil? || binding_name.is_a?(String)
|
|
80
|
+
return binding_name unless binding_name.nil? || binding_name.empty?
|
|
81
|
+
|
|
82
|
+
raise Error, "config/cache.yml: #{@env} has store: kv but no binding: (the name of a [[kv_namespaces]] binding in wrangler.toml)"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def checked_unused_binding(binding_name)
|
|
86
|
+
raise Error, "config/cache.yml: binding: is only for store: kv (in #{@env})" unless binding_name.nil?
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def checked_namespace(namespace)
|
|
92
|
+
return nil if namespace.nil?
|
|
93
|
+
raise Error, "config/cache.yml: namespace in #{@env} must be a non-empty String" unless namespace.is_a?(String) && !namespace.empty?
|
|
94
|
+
return namespace if namespace.valid_encoding? && !namespace.include?("\0")
|
|
95
|
+
|
|
96
|
+
raise Error, "config/cache.yml: namespace in #{@env} must be valid UTF-8 without a NUL byte"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def checked_expires_in(seconds)
|
|
100
|
+
return nil if seconds.nil?
|
|
101
|
+
return seconds if seconds.is_a?(Integer) && seconds.between?(1, Edgeway::Cache::MAX_EXPIRES_IN)
|
|
102
|
+
|
|
103
|
+
raise Error, "config/cache.yml: expires_in in #{@env} must be a whole number of seconds, 1 to #{Edgeway::Cache::MAX_EXPIRES_IN}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -17,6 +17,10 @@ module Edgeway
|
|
|
17
17
|
section
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
def section?(env)
|
|
21
|
+
File.exist?(path) && sections[env].is_a?(Hash)
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
def databases_in(section, env)
|
|
21
25
|
entries = section["databases"] || {}
|
|
22
26
|
raise Error, "config/database.yml: databases in #{env} must be a Hash" unless entries.is_a?(Hash)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Edgeway
|
|
7
|
+
module CLI
|
|
8
|
+
class Build
|
|
9
|
+
module JsFiles
|
|
10
|
+
TEST_ONLY = /-test(-[^\/]*)?\.js\z/
|
|
11
|
+
|
|
12
|
+
def self.copy(destination, config_js, tests: false)
|
|
13
|
+
FileUtils.mkdir_p(destination)
|
|
14
|
+
paths(tests).each { |file| FileUtils.cp(file, destination) }
|
|
15
|
+
File.write(File.join(destination, "config.js"), config_js)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.paths(tests)
|
|
19
|
+
files = Dir.glob(File.join(CLI.gem_dir, "js/*.js"))
|
|
20
|
+
tests ? files : files.grep_v(TEST_ONLY)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.config_js(values, command)
|
|
24
|
+
"// Generated by `edgeway #{command}` from config/database.yml; do not edit.\n" \
|
|
25
|
+
"export default #{JSON.generate(values)};\n"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/edgeway/cli/build.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "optparse"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "build/app_config"
|
|
6
6
|
require_relative "build/app_files"
|
|
7
|
+
require_relative "build/cache_config"
|
|
8
|
+
require_relative "build/js_files"
|
|
7
9
|
require_relative "build/view_compiler"
|
|
8
10
|
|
|
9
11
|
module Edgeway
|
|
@@ -16,8 +18,9 @@ module Edgeway
|
|
|
16
18
|
# Spinel resolves require at compile time and has no autoloading, so
|
|
17
19
|
# build/main.rb requires every file, in an order where a class body finds
|
|
18
20
|
# what it names (a superclass, an extended module):
|
|
19
|
-
# 1. edgeway, the database adapter config/database.yml names,
|
|
20
|
-
# verifier
|
|
21
|
+
# 1. edgeway, the database adapter config/database.yml names, the JWT
|
|
22
|
+
# verifier, the HTTP client, the rate limiter and the cache store
|
|
23
|
+
# config/cache.yml names (when it needs a file)
|
|
21
24
|
# 2. app/*/ other than models/ and controllers/ (services, ...)
|
|
22
25
|
# 3. app/models/
|
|
23
26
|
# 4. app/controllers/
|
|
@@ -57,15 +60,23 @@ module Edgeway
|
|
|
57
60
|
private
|
|
58
61
|
|
|
59
62
|
def write_main(config)
|
|
63
|
+
cache = CacheConfig.load(@root, @env)
|
|
60
64
|
lines = ["# Generated by `edgeway build` from app/ and config/; do not edit.", 'require "edgeway"']
|
|
61
65
|
lines << "require #{config.adapter_file.dump}"
|
|
62
|
-
# Always: js/cloudflare.js supplies the edgeway_jwt
|
|
63
|
-
# and
|
|
66
|
+
# Always: js/cloudflare.js supplies the edgeway_jwt, edgeway_fetch and
|
|
67
|
+
# edgeway_ratelimit imports to every app, and each is only called by an
|
|
68
|
+
# app that authenticates, calls out or rate limits.
|
|
64
69
|
lines << 'require "edgeway/service/cloudflare/jwt"'
|
|
70
|
+
lines << 'require "edgeway/service/fetch"'
|
|
71
|
+
lines << 'require "edgeway/service/cloudflare/rate_limit"'
|
|
72
|
+
lines << "require #{cache.backend_file.dump}" if cache.backend_file
|
|
65
73
|
lines << ""
|
|
66
74
|
lines << connection_line(config)
|
|
67
75
|
config.databases.each { |name, binding_name| lines << connect_line(config, name, binding_name) }
|
|
68
76
|
lines << "Edgeway::Auth.verifier = Edgeway::Cloudflare::JWTVerifier.new"
|
|
77
|
+
lines << "Edgeway::HTTP.client = Edgeway::FetchTransport.new"
|
|
78
|
+
lines << "Edgeway.cache = #{cache.store_expression}"
|
|
79
|
+
lines << "Edgeway::Controller.rate_limiter = Edgeway::Cloudflare::RateLimiter.new(transport: Edgeway::Cloudflare::RateLimitImportTransport.new)"
|
|
69
80
|
lines << ""
|
|
70
81
|
views = ViewCompiler.new(@root).compile
|
|
71
82
|
File.write(File.join(@root, "build/views.rb"), views) if views
|
|
@@ -91,16 +102,12 @@ module Edgeway
|
|
|
91
102
|
end
|
|
92
103
|
|
|
93
104
|
def copy_js(config)
|
|
94
|
-
|
|
95
|
-
FileUtils.mkdir_p(dest)
|
|
96
|
-
Dir.glob(File.join(CLI.gem_dir, "js/*.js")).each { |f| FileUtils.cp(f, dest) }
|
|
97
|
-
File.write(File.join(dest, "config.js"), config_js(config))
|
|
105
|
+
JsFiles.copy(File.join(@root, "build/edgeway"), config_js(config))
|
|
98
106
|
end
|
|
99
107
|
|
|
100
108
|
def config_js(config)
|
|
101
109
|
values = { "env" => config.env, "d1Binding" => config.binding_name, "sessions" => config.sessions }
|
|
102
|
-
|
|
103
|
-
"export default #{JSON.generate(values)};\n"
|
|
110
|
+
JsFiles.config_js(values, "build")
|
|
104
111
|
end
|
|
105
112
|
|
|
106
113
|
def compile
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module CLI
|
|
5
|
+
class CloudflareTest
|
|
6
|
+
class WasmTestCLI < SpinelTest::Unit::CLI
|
|
7
|
+
def initialize(argv, root:, launcher:)
|
|
8
|
+
super(argv, root: root)
|
|
9
|
+
@launcher = launcher
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def compile(spinel, main, bin, entries)
|
|
15
|
+
wasm = File.join(@root, "tmp/test_app.wasm")
|
|
16
|
+
command = [spinel, "--target=wasm32-wasi", "--incremental"] +
|
|
17
|
+
include_dirs(entries).flat_map { |directory| ["-I", directory] } + [main, "-o", wasm]
|
|
18
|
+
output, status = Open3.capture2e(*command)
|
|
19
|
+
unless status.success?
|
|
20
|
+
warn output unless output.empty?
|
|
21
|
+
warn "edgeway test: compilation failed (#{command.join(" ")})"
|
|
22
|
+
return false
|
|
23
|
+
end
|
|
24
|
+
write_launcher(bin, wasm)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write_launcher(bin, wasm)
|
|
28
|
+
File.write(bin, "#!/bin/sh\nexec #{Shellwords.join(@launcher + [wasm])} \"$@\"\n")
|
|
29
|
+
File.chmod(0o755, bin)
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Edgeway
|
|
7
|
+
module CLI
|
|
8
|
+
class CloudflareTest < Test
|
|
9
|
+
RUNTIME_DIR = "tmp/edgeway"
|
|
10
|
+
RUNNER = "cloudflare-test-runner.js"
|
|
11
|
+
MIN_NODE_MAJOR = 24
|
|
12
|
+
|
|
13
|
+
# The apps whose test environment uses the d1 adapter.
|
|
14
|
+
def self.handles?(database)
|
|
15
|
+
database.section?("test") && database.section("test")["adapter"] == "d1"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(root)
|
|
19
|
+
super
|
|
20
|
+
@config = Build::AppConfig.load(root, "test")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(args)
|
|
24
|
+
check_prerequisites
|
|
25
|
+
write_test_app
|
|
26
|
+
write_runtime
|
|
27
|
+
require_spinel_test_unit
|
|
28
|
+
argv = ["-I", @root, "-I", CLI.gem_dir + "/lib", *args]
|
|
29
|
+
exit WasmTestCLI.new(argv, root: @root, launcher: launcher).run
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def database_lines
|
|
35
|
+
[
|
|
36
|
+
'require "edgeway/db/memory"',
|
|
37
|
+
'require "edgeway/service/cloudflare/test_databases"',
|
|
38
|
+
'require "edgeway/service/cloudflare/jwt"',
|
|
39
|
+
'require "edgeway/service/fetch"',
|
|
40
|
+
"module Edgeway; TEST_DATABASES = #{@config.databases.keys.inspect}; end",
|
|
41
|
+
"module Edgeway; TEST_BINDINGS = #{bindings.inspect}; end"
|
|
42
|
+
]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bindings
|
|
46
|
+
{ "primary" => @config.binding_name }.merge(@config.databases)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def migration_directories
|
|
50
|
+
{ "primary" => "db/migrations" }.merge(@config.databases.to_h { |name, _binding| [name, "db/#{name}/migrations"] })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def write_runtime
|
|
54
|
+
databases = bindings.map do |name, binding_name|
|
|
55
|
+
{ "binding" => binding_name, "migrations" => File.join(@root, migration_directories.fetch(name)) }
|
|
56
|
+
end
|
|
57
|
+
values = {
|
|
58
|
+
"env" => "test", "d1Binding" => @config.binding_name, "sessions" => @config.sessions,
|
|
59
|
+
"appRoot" => @root, "databases" => databases
|
|
60
|
+
}
|
|
61
|
+
directory = File.join(@root, RUNTIME_DIR)
|
|
62
|
+
Build::JsFiles.copy(directory, Build::JsFiles.config_js(values, "test"), tests: true)
|
|
63
|
+
File.write(File.join(directory, "package.json"), %({"type": "module"}\n))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def launcher
|
|
67
|
+
["node", *node_flags, File.join(@root, RUNTIME_DIR, RUNNER)]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def node_flags
|
|
71
|
+
has_jspi = system("node", "-e", "process.exit(typeof WebAssembly.Suspending === 'function' ? 0 : 1)")
|
|
72
|
+
has_jspi ? [] : ["--experimental-wasm-jspi", "--experimental-wasm-exnref"]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def require_spinel_test_unit
|
|
76
|
+
require "spinel_test/unit/cli"
|
|
77
|
+
require_relative "cloudflare_test/wasm_test_cli"
|
|
78
|
+
rescue LoadError
|
|
79
|
+
raise Error, 'spinel_test-unit is not installed (add gem "spinel_test-unit", "~> 0.1", group: :test to the Gemfile and run bundle install)'
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def check_prerequisites
|
|
83
|
+
check_node
|
|
84
|
+
raise Error, "no wrangler.toml in #{@root} (the tests run on the local D1 it describes)" unless File.exist?(File.join(@root, "wrangler.toml"))
|
|
85
|
+
|
|
86
|
+
check_wrangler
|
|
87
|
+
raise Error, "no WASI SDK found (install it with mise, or set WASI_SDK=/path/to/wasi-sdk)" unless Toolchain.wasi_sdk
|
|
88
|
+
raise Error, "no Spinel compiler found (install it with mise, or set SPINEL=/path/to/spinel)" unless Toolchain.spinel
|
|
89
|
+
|
|
90
|
+
check_migration_directories
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check_node
|
|
94
|
+
raise Error, "node is not installed; `edgeway test` runs the tests on the local D1 of wrangler, which needs Node.js #{MIN_NODE_MAJOR} or later" unless Toolchain.find_on_path("node")
|
|
95
|
+
|
|
96
|
+
version, = Open3.capture2("node", "-p", "process.versions.node")
|
|
97
|
+
return if version.to_i >= MIN_NODE_MAJOR
|
|
98
|
+
|
|
99
|
+
raise Error, "Node.js #{version.strip} is too old: `edgeway test` needs #{MIN_NODE_MAJOR} or later (WebAssembly JSPI)"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check_wrangler
|
|
103
|
+
return if File.directory?(File.join(@root, "node_modules/wrangler"))
|
|
104
|
+
|
|
105
|
+
raise Error, "wrangler is not installed in #{@root}/node_modules; run #{PackageManager.install} (or bin/setup)"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def check_migration_directories
|
|
109
|
+
@config.databases.each_key do |name|
|
|
110
|
+
directory = migration_directories.fetch(name)
|
|
111
|
+
next if File.directory?(File.join(@root, directory))
|
|
112
|
+
|
|
113
|
+
raise Error, "database #{name} (config/database.yml) has no #{directory} directory"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Test::PLATFORMS << CloudflareTest
|
|
119
|
+
end
|
|
120
|
+
end
|
data/lib/edgeway/cli/test.rb
CHANGED
|
@@ -3,34 +3,50 @@
|
|
|
3
3
|
module Edgeway
|
|
4
4
|
module CLI
|
|
5
5
|
class Test
|
|
6
|
+
PLATFORMS = []
|
|
7
|
+
|
|
6
8
|
def self.run(args)
|
|
7
9
|
root = Dir.pwd
|
|
8
|
-
|
|
9
|
-
new(root).
|
|
10
|
-
exec("spinel_test-unit", "-I", root, "-I", CLI.gem_dir + "/lib", *args)
|
|
10
|
+
database = Build::DatabaseConfig.new(root)
|
|
11
|
+
(PLATFORMS.find { |platform| platform.handles?(database) } || Test).new(root).run(args)
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def initialize(root)
|
|
14
15
|
@root = root
|
|
15
16
|
end
|
|
16
17
|
|
|
18
|
+
def run(args)
|
|
19
|
+
Precheck.sqlite!
|
|
20
|
+
write_test_app
|
|
21
|
+
exec("spinel_test-unit", "-I", @root, "-I", CLI.gem_dir + "/lib", *args)
|
|
22
|
+
end
|
|
23
|
+
|
|
17
24
|
def write_test_app
|
|
18
25
|
raise Error, "no config/routes.rb in #{@root}" unless File.exist?(File.join(@root, "config/routes.rb"))
|
|
19
26
|
|
|
20
27
|
FileUtils.mkdir_p(File.join(@root, "tmp"))
|
|
21
28
|
lines = ["# Generated by `edgeway test` from app/ and config/; do not edit.", 'require "edgeway"']
|
|
22
|
-
lines
|
|
23
|
-
lines << 'require "edgeway/db/sqlite"'
|
|
24
|
-
# Where the migrations are read from at test time (the binary may run
|
|
25
|
-
# from another directory); Edgeway::UnitTest#setup applies them.
|
|
26
|
-
lines << "module Edgeway; TEST_APP_ROOT = #{@root.dump}; end"
|
|
27
|
-
lines << "module Edgeway; TEST_DATABASES = #{Build::DatabaseConfig.new(@root).names.inspect}; end"
|
|
29
|
+
lines.concat(database_lines)
|
|
28
30
|
lines << ""
|
|
29
31
|
views = Build::ViewCompiler.new(@root).compile
|
|
30
32
|
File.write(File.join(@root, "tmp/views.rb"), views) if views
|
|
31
33
|
lines.concat(Build::AppFiles.new(@root).require_lines(views: views && "views"))
|
|
32
34
|
File.write(File.join(@root, "tmp/test_app.rb"), lines.join("\n") + "\n")
|
|
33
35
|
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def database_lines
|
|
40
|
+
[
|
|
41
|
+
'require "edgeway/db/memory"',
|
|
42
|
+
'require "edgeway/db/sqlite"',
|
|
43
|
+
'require "edgeway/test/sqlite_databases"',
|
|
44
|
+
# Where the migrations are read from at test time (the binary may run
|
|
45
|
+
# from another directory); Edgeway::TestDatabases applies them.
|
|
46
|
+
"module Edgeway; TEST_APP_ROOT = #{@root.dump}; end",
|
|
47
|
+
"module Edgeway; TEST_DATABASES = #{Build::DatabaseConfig.new(@root).names.inspect}; end"
|
|
48
|
+
]
|
|
49
|
+
end
|
|
34
50
|
end
|
|
35
51
|
end
|
|
36
52
|
end
|