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
@@ -0,0 +1,8 @@
1
+ module Edgeway
2
+ module Cache
3
+ # A year. In wasm, Integers are 32 bits: epoch seconds plus an expires_in
4
+ # stop fitting in 2038, so a longer expiry is refused rather than allowed
5
+ # to overflow.
6
+ MAX_EXPIRES_IN = 31_536_000
7
+ end
8
+ end
@@ -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,13 @@
1
+ require_relative "store"
2
+
3
+ module Edgeway
4
+ module Cache
5
+ class NullStore < Store
6
+ def read_text(key) = nil
7
+
8
+ def write_text(key, text, expires_in_seconds) = true
9
+
10
+ def delete_text(key) = true
11
+ end
12
+ end
13
+ 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,13 @@
1
+ require_relative "cache/store"
2
+ require_relative "cache/null_store"
3
+ require_relative "cache/memory_store"
4
+
5
+ module Edgeway
6
+ @cache = Cache::NullStore.new
7
+
8
+ def self.cache = @cache
9
+
10
+ def self.cache=(store)
11
+ @cache = store
12
+ end
13
+ 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
@@ -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, and the JWT
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 import to every app,
63
- # and the verifier is only called by an app that authenticates.
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
- dest = File.join(@root, "build/edgeway")
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
- "// Generated by `edgeway build` from config/database.yml; do not edit.\n" \
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
@@ -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
- Precheck.sqlite!
9
- new(root).write_test_app
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 << 'require "edgeway/db/memory"'
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