edgeway 0.1.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +784 -125
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare-kv.js +47 -0
  6. data/js/cloudflare-ratelimit.js +31 -0
  7. data/js/cloudflare-test-database.js +112 -0
  8. data/js/cloudflare-test-runner.js +76 -0
  9. data/js/cloudflare.js +144 -22
  10. data/js/cors.js +15 -16
  11. data/js/fetch.js +113 -0
  12. data/js/headers.js +16 -0
  13. data/js/jspi.js +32 -0
  14. data/js/jwt.js +6 -40
  15. data/js/runtime.js +42 -5
  16. data/lib/edgeway/application.rb +21 -9
  17. data/lib/edgeway/cache/limits.rb +8 -0
  18. data/lib/edgeway/cache/memory_store.rb +24 -0
  19. data/lib/edgeway/cache/null_store.rb +13 -0
  20. data/lib/edgeway/cache/store.rb +189 -0
  21. data/lib/edgeway/cache.rb +13 -0
  22. data/lib/edgeway/cli/build/app_config.rb +39 -0
  23. data/lib/edgeway/cli/build/app_files.rb +40 -0
  24. data/lib/edgeway/cli/build/cache_config.rb +108 -0
  25. data/lib/edgeway/cli/build/database_config.rb +96 -0
  26. data/lib/edgeway/cli/build/js_files.rb +30 -0
  27. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  28. data/lib/edgeway/cli/build.rb +50 -18
  29. data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
  30. data/lib/edgeway/cli/cloudflare_test.rb +120 -0
  31. data/lib/edgeway/cli/generate.rb +174 -26
  32. data/lib/edgeway/cli/new.rb +1 -1
  33. data/lib/edgeway/cli/test.rb +28 -9
  34. data/lib/edgeway/cli/toolchain.rb +5 -0
  35. data/lib/edgeway/cli.rb +7 -4
  36. data/lib/edgeway/controller/base.rb +32 -3
  37. data/lib/edgeway/controller/caching.rb +140 -0
  38. data/lib/edgeway/controller/callbacks.rb +5 -1
  39. data/lib/edgeway/controller/dispatcher.rb +11 -1
  40. data/lib/edgeway/controller/rate_limiting.rb +145 -0
  41. data/lib/edgeway/controller/rendering.rb +24 -4
  42. data/lib/edgeway/db/adapter.rb +38 -0
  43. data/lib/edgeway/db/memory.rb +9 -2
  44. data/lib/edgeway/db/query_stats.rb +42 -0
  45. data/lib/edgeway/db/sqlite.rb +73 -9
  46. data/lib/edgeway/errors.rb +43 -0
  47. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  48. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  49. data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
  50. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  51. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  52. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  53. data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
  54. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  55. data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
  56. data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
  57. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  58. data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
  59. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
  60. data/lib/edgeway/http/auth/stub.rb +1 -3
  61. data/lib/edgeway/http/client.rb +80 -0
  62. data/lib/edgeway/http/request.rb +7 -22
  63. data/lib/edgeway/http/stub_transport.rb +38 -0
  64. data/lib/edgeway/inflector.rb +19 -0
  65. data/lib/edgeway/logger.rb +33 -5
  66. data/lib/edgeway/model/attributes.rb +93 -10
  67. data/lib/edgeway/model/base.rb +50 -5
  68. data/lib/edgeway/model/connection_registry.rb +25 -0
  69. data/lib/edgeway/model/macros.rb +5 -1
  70. data/lib/edgeway/model/persistence.rb +18 -11
  71. data/lib/edgeway/model/relation.rb +106 -56
  72. data/lib/edgeway/model/validations.rb +29 -23
  73. data/lib/edgeway/rate_limit/limiter.rb +9 -0
  74. data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
  75. data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
  76. data/lib/edgeway/rate_limit.rb +2 -0
  77. data/lib/edgeway/routing/route.rb +1 -6
  78. data/lib/edgeway/server.rb +13 -0
  79. data/lib/edgeway/service/cloudflare/d1.rb +44 -30
  80. data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
  81. data/lib/edgeway/service/cloudflare/kv.rb +45 -0
  82. data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
  83. data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
  84. data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
  85. data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
  86. data/lib/edgeway/service/fetch.rb +26 -0
  87. data/lib/edgeway/service/import_result.rb +24 -0
  88. data/lib/edgeway/test/sqlite_databases.rb +18 -0
  89. data/lib/edgeway/test.rb +86 -26
  90. data/lib/edgeway/version.rb +1 -1
  91. data/lib/edgeway/view.rb +24 -0
  92. data/lib/edgeway.rb +9 -0
  93. metadata +50 -4
  94. data/lib/edgeway/cli/app_config.rb +0 -35
  95. data/lib/edgeway/cli/app_files.rb +0 -37
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ class Build
6
+ class AppFiles
7
+ def initialize(root)
8
+ @root = root
9
+ end
10
+
11
+ def require_lines(views: nil)
12
+ lines = []
13
+ app = File.join(@root, "app")
14
+ dirs = Dir.exist?(app) ? Dir.children(app).sort.select { |d| File.directory?(File.join(app, d)) } : []
15
+ dirs.each do |d|
16
+ next if %w[models controllers].include?(d)
17
+
18
+ lines.concat(requires("app/#{d}"))
19
+ end
20
+ lines.concat(requires("app/models"))
21
+ lines.concat(requires("app/controllers"))
22
+ lines << "require_relative #{views.dump}" if views
23
+ lines << 'require_relative "../config/routes"'
24
+ lines
25
+ end
26
+
27
+ # require_relative lines for the .rb files under dir, application_*.rb first.
28
+ def requires(dir)
29
+ full = File.join(@root, dir)
30
+ return [] unless Dir.exist?(full)
31
+
32
+ files = Dir.glob("**/*.rb", base: full).map { |f| File.join(dir, f) }
33
+ first, rest = files.partition { |f| File.basename(f).start_with?("application_") }
34
+ # String#dump quotes a path with spaces, quotes or "#{" safely.
35
+ (first.sort + rest.sort).map { |f| "require_relative #{"../#{f.delete_suffix(".rb")}".dump}" }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ 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
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ class Build
6
+ class DatabaseConfig
7
+ DATABASE_NAME = /\A[a-z_][a-z0-9_]*\z/
8
+
9
+ def initialize(root)
10
+ @root = root
11
+ end
12
+
13
+ def section(env)
14
+ section = sections[env]
15
+ raise Error, "config/database.yml has no #{env} section" unless section.is_a?(Hash)
16
+
17
+ section
18
+ end
19
+
20
+ def section?(env)
21
+ File.exist?(path) && sections[env].is_a?(Hash)
22
+ end
23
+
24
+ def databases_in(section, env)
25
+ entries = section["databases"] || {}
26
+ raise Error, "config/database.yml: databases in #{env} must be a Hash" unless entries.is_a?(Hash)
27
+
28
+ entries.to_h do |name, entry|
29
+ name = name.to_s
30
+ [name, database_binding(name, entry, env)]
31
+ end
32
+ end
33
+
34
+ def primary_binding(section) = (section["binding"] || "DB").to_s
35
+
36
+ def session_bindings(section, env)
37
+ flags = {}
38
+ record_sessions(flags, "the #{env} section", primary_binding(section), section, "the #{env} section")
39
+ (section["databases"] || {}).each do |name, entry|
40
+ record_sessions(flags, "databases.#{name}", entry["binding"].to_s, entry, "databases.#{name} in #{env}")
41
+ end
42
+ flags.select { |_binding, (_label, enabled)| enabled }.keys
43
+ end
44
+
45
+ def names
46
+ names = []
47
+ return names unless File.exist?(path)
48
+
49
+ sections.each do |env, section|
50
+ next unless section.is_a?(Hash)
51
+ databases_in(section, env).each_key { |name| names << name unless names.include?(name) }
52
+ end
53
+ names
54
+ end
55
+
56
+ private
57
+
58
+ def path = File.join(@root, "config", "database.yml")
59
+
60
+ def sections
61
+ raise Error, "no config/database.yml in #{@root} (is this an Edgeway app?)" unless File.exist?(path)
62
+
63
+ YAML.safe_load_file(path) || {}
64
+ end
65
+
66
+ def record_sessions(flags, label, binding_name, entry, where)
67
+ enabled = sessions?(entry, where)
68
+ known_label, known = flags[binding_name]
69
+ if known_label && known != enabled
70
+ raise Error, "config/database.yml: #{known_label} and #{label} share binding #{binding_name} but differ in sessions"
71
+ end
72
+
73
+ flags[binding_name] = [label, enabled]
74
+ end
75
+
76
+ def sessions?(entry, where)
77
+ return false unless entry.is_a?(Hash) && entry.key?("sessions")
78
+ return entry["sessions"] if [true, false].include?(entry["sessions"])
79
+
80
+ raise Error, "config/database.yml: sessions in #{where} must be true or false"
81
+ end
82
+
83
+ def database_binding(name, entry, env)
84
+ unless name.match?(DATABASE_NAME) && name != "primary"
85
+ raise Error, "config/database.yml: bad database name #{name.inspect} in #{env} (lowercase letters, digits and _, and not primary)"
86
+ end
87
+
88
+ binding_name = entry.is_a?(Hash) ? entry["binding"].to_s : ""
89
+ raise Error, "config/database.yml: databases.#{name} in #{env} has no binding:" if binding_name.empty?
90
+
91
+ binding_name
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -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
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../inflector"
4
+
5
+ module Edgeway
6
+ module CLI
7
+ class Build
8
+ # app/views/**/*.html.erb -> Ruby that reopens the controllers.
9
+ #
10
+ # Spinel has no eval and no binding, so a template can't be read at
11
+ # runtime: each one becomes an instance method of its controller (it sees
12
+ # the action's @variables, params and helpers as any method of it does),
13
+ # and `edgeway build` / `edgeway test` require the result after the
14
+ # controllers.
15
+ #
16
+ # app/views/todos/index.html.erb TodosController#_view_todos_index
17
+ # app/views/layouts/application.html.erb
18
+ # ApplicationController#_render_layout
19
+ #
20
+ # A controller gets `_render_view(action)` too: a case over its templates,
21
+ # not a send, since with a computed name Spinel's send misses the methods
22
+ # of a subclass (matz/spinel#8592).
23
+ #
24
+ # The ERB it reads is a small subset (stdlib ERB is not used):
25
+ # <%= expr %> expr, HTML-escaped
26
+ # <%== expr %> expr, as it is
27
+ # <% code %> code
28
+ # <%# text %> nothing
29
+ # <%% a literal <%
30
+ # <%- starts a tag and drops the indentation before it
31
+ # -%> ends a tag and drops the newline after it
32
+ # and `<%= yield %>` (or `<%== yield %>`) in the layout is the page, as
33
+ # it is.
34
+ class ViewCompiler
35
+ EXT = ".html.erb"
36
+ LAYOUT = "layouts/application#{EXT}"
37
+ NAME = /\A[a-z_][a-z0-9_]*\z/
38
+
39
+ def initialize(root)
40
+ @root = root
41
+ end
42
+
43
+ # The Ruby for app/views, nil when the app has none.
44
+ def compile
45
+ views = File.join(@root, "app/views")
46
+ return nil unless Dir.exist?(views)
47
+
48
+ files = Dir.glob("**/*#{EXT}", base: views).sort
49
+ layout = files.delete(LAYOUT)
50
+ files.reject! { |f| f.start_with?("layouts/") }
51
+ by_dir = {}
52
+ files.each do |f|
53
+ dir = File.dirname(f)
54
+ raise Error, "app/views/#{f}: a template goes in a controller's directory (app/views/todos/index#{EXT})" if dir == "."
55
+
56
+ (by_dir[dir] ||= []) << f
57
+ end
58
+
59
+ out = ["# Generated by `edgeway build` from app/views; do not edit."]
60
+ out.concat(layout_source(layout)) if layout
61
+ by_dir.each { |dir, fs| out.concat(controller_source(dir, fs)) }
62
+ out.join("\n") + "\n"
63
+ end
64
+
65
+ def template_lines(text, path, layout: false)
66
+ lines = []
67
+ pos = 0
68
+ literal = +""
69
+ # the line of text[counted], advanced as the scan goes
70
+ counted = 0
71
+ line = 1
72
+ while (open = text.index("<%", pos))
73
+ if text[open + 2] == "%"
74
+ literal << text[pos...open] << "<%"
75
+ pos = open + 3
76
+ next
77
+ end
78
+ line += text[counted...open].count("\n")
79
+ counted = open
80
+ close = text.index("%>", open + 2) or raise Error, "#{path}:#{line}: unclosed <%"
81
+ literal << text[pos...open]
82
+ body = text[(open + 2)...close]
83
+ if body.start_with?("-")
84
+ body = body[1..]
85
+ # <%- also drops the indentation before it on its line
86
+ line_start = open > 0 ? (text.rindex("\n", open - 1) || -1) + 1 : 0
87
+ indent = open - line_start
88
+ literal = literal[0...(literal.length - indent)] if indent > 0 && text[line_start...open].strip.empty?
89
+ end
90
+ trim = body.end_with?("-")
91
+ body = body.chomp("-") if trim
92
+ after = close + 2
93
+ if trim && text[after] == "\n"
94
+ after += 1
95
+ elsif trim && text[after, 2] == "\r\n"
96
+ after += 2
97
+ end
98
+ flush(lines, literal)
99
+ code = body.strip
100
+ if body.start_with?("#")
101
+ elsif body.start_with?("==")
102
+ expr = code[2..].strip
103
+ lines << "# #{path}:#{line}" << output(expr, "(", ").to_s", layout, path, line)
104
+ elsif body.start_with?("=")
105
+ expr = code[1..].strip
106
+ lines << "# #{path}:#{line}" << output(expr, "Edgeway::View.escape(", ")", layout, path, line)
107
+ else
108
+ lines << "# #{path}:#{line}" << code
109
+ end
110
+ pos = after
111
+ end
112
+ literal << text[pos..]
113
+ flush(lines, literal)
114
+ lines
115
+ end
116
+
117
+ private
118
+
119
+ # An output tag; the closing paren goes on its own line so that a
120
+ # trailing `# comment` in the tag doesn't swallow it. `yield` is the
121
+ # page in the layout.
122
+ def output(expr, head, tail, layout, path, line)
123
+ if expr == "yield"
124
+ raise Error, "#{path}:#{line}: yield is only for the layout" unless layout
125
+
126
+ return "out << content"
127
+ end
128
+ "out << #{head}#{expr}\n#{tail}"
129
+ end
130
+
131
+ def flush(lines, literal)
132
+ return if literal.empty?
133
+
134
+ lines << "out << #{literal.dump}"
135
+ literal.clear
136
+ end
137
+
138
+ def layout_source(file)
139
+ path = "app/views/#{file}"
140
+ controller_file!("application", path)
141
+ body = template_lines(File.read(File.join(@root, path)), path, layout: true)
142
+ wrap("ApplicationController", method_source("_render_layout(content)", body, path))
143
+ end
144
+
145
+ def controller_source(dir, files)
146
+ controller_file!(dir, "app/views/#{dir}/")
147
+ methods = []
148
+ actions = []
149
+ files.each do |f|
150
+ path = "app/views/#{f}"
151
+ action = File.basename(f).delete_suffix(EXT)
152
+ raise Error, "#{path}: #{action.inspect} is not a valid action name" unless NAME.match?(action)
153
+ dir.split("/").each { |part| raise Error, "#{path}: #{part.inspect} is not a valid controller path" unless NAME.match?(part) }
154
+
155
+ name = "_view_#{dir.tr("/", "_")}_#{action}"
156
+ methods.concat(method_source(name, template_lines(File.read(File.join(@root, path)), path), path))
157
+ methods << ""
158
+ actions << [action, name]
159
+ end
160
+ dispatch = ["def _render_view(action)", " case action"]
161
+ actions.each { |action, name| dispatch << " when #{action.dump} then #{name}" }
162
+ dispatch << " else super" << " end" << "end"
163
+ wrap(Inflector.controller_class_name(dir), methods + dispatch)
164
+ end
165
+
166
+ def method_source(signature, body, path)
167
+ ["def #{signature}", " # #{path}", " out = +\"\""] +
168
+ body.map { |l| " #{l}" } + [" out", "end"]
169
+ end
170
+
171
+ def wrap(klass, body)
172
+ *modules, name = klass.split("::")
173
+ depth = modules.size
174
+ lines = [""]
175
+ modules.each_with_index { |m, i| lines << "#{" " * i}module #{m}" }
176
+ lines << "#{" " * depth}class #{name}"
177
+ body.each { |l| lines << (l.empty? ? "" : "#{" " * (depth + 1)}#{l}") }
178
+ lines << "#{" " * depth}end"
179
+ modules.size.times { |i| lines << "#{" " * (modules.size - 1 - i)}end" }
180
+ lines
181
+ end
182
+
183
+ def controller_file!(dir, what)
184
+ return if File.exist?(File.join(@root, "app/controllers/#{dir}_controller.rb"))
185
+
186
+ raise Error, "#{what} has no controller: app/controllers/#{dir}_controller.rb does not exist"
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -2,36 +2,48 @@
2
2
 
3
3
  require "optparse"
4
4
  require "json"
5
+ require_relative "build/app_config"
6
+ require_relative "build/app_files"
7
+ require_relative "build/cache_config"
8
+ require_relative "build/js_files"
9
+ require_relative "build/view_compiler"
5
10
 
6
11
  module Edgeway
7
12
  module CLI
8
13
  # edgeway build: app/, config/ -> build/main.rb -> build/app.wasm, and the
9
- # gem's js/ -> build/edgeway/.
14
+ # gem's js/ -> build/edgeway/. Spinel compiles with --incremental (cached C
15
+ # units, fast rebuilds, no inlining across units) unless --release, which
16
+ # builds as one unit for deploying.
10
17
  #
11
18
  # Spinel resolves require at compile time and has no autoloading, so
12
19
  # build/main.rb requires every file, in an order where a class body finds
13
20
  # what it names (a superclass, an extended module):
14
- # 1. edgeway, the database adapter config/database.yml names, and the JWT
15
- # 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)
16
24
  # 2. app/*/ other than models/ and controllers/ (services, ...)
17
25
  # 3. app/models/
18
26
  # 4. app/controllers/
19
- # 5. config/routes.rb
27
+ # 5. build/views.rb, the Ruby compiled from app/views (when there is one)
28
+ # 6. config/routes.rb
20
29
  # Within each, application_*.rb come first (the base classes), then the
21
30
  # rest by path (byte order).
22
31
  class Build
23
32
  def self.run(args)
24
33
  env = "development"
34
+ release = false
25
35
  OptionParser.new do |o|
26
- o.banner = "Usage: edgeway build [--env ENV]"
36
+ o.banner = "Usage: edgeway build [--env ENV] [--release]"
27
37
  o.on("--env ENV", "environment (default: development)") { |v| env = v }
38
+ o.on("--release", "build as one unit, without --incremental (for deploying)") { release = true }
28
39
  end.parse!(args)
29
- new(Dir.pwd, env).run
40
+ new(Dir.pwd, env, release:).run
30
41
  end
31
42
 
32
- def initialize(root, env)
43
+ def initialize(root, env, release: false)
33
44
  @root = root
34
45
  @env = env
46
+ @release = release
35
47
  end
36
48
 
37
49
  def run
@@ -48,40 +60,60 @@ module Edgeway
48
60
  private
49
61
 
50
62
  def write_main(config)
63
+ cache = CacheConfig.load(@root, @env)
51
64
  lines = ["# Generated by `edgeway build` from app/ and config/; do not edit.", 'require "edgeway"']
52
65
  lines << "require #{config.adapter_file.dump}"
53
- # Always: js/cloudflare.js supplies the edgeway_jwt import to every app,
54
- # 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.
55
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
56
73
  lines << ""
57
74
  lines << connection_line(config)
75
+ config.databases.each { |name, binding_name| lines << connect_line(config, name, binding_name) }
58
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)"
59
80
  lines << ""
60
- lines.concat(AppFiles.new(@root).require_lines)
81
+ views = ViewCompiler.new(@root).compile
82
+ File.write(File.join(@root, "build/views.rb"), views) if views
83
+ lines.concat(AppFiles.new(@root).require_lines(views: views && "views"))
61
84
  lines << ""
62
85
  lines << "Edgeway::Server.run"
63
86
  File.write(File.join(@root, "build/main.rb"), lines.join("\n") + "\n")
64
87
  end
65
88
 
66
89
  def connection_line(config)
90
+ "Edgeway::Model.connection = #{adapter_expression(config, config.binding_name)}"
91
+ end
92
+
93
+ def connect_line(config, name, binding_name)
94
+ "Edgeway::Model.connect(#{name.inspect}, #{adapter_expression(config, binding_name)})"
95
+ end
96
+
97
+ def adapter_expression(config, binding_name)
67
98
  case config.adapter
68
- when "d1" then %(Edgeway::Model.connection = Edgeway::Cloudflare::D1Adapter.new(binding: #{config.binding_name.inspect}))
69
- else "Edgeway::Model.connection = Edgeway::DB::MemoryAdapter.new"
99
+ when "d1" then "Edgeway::Cloudflare::D1Adapter.new(binding: #{binding_name.inspect})"
100
+ else "Edgeway::DB::MemoryAdapter.new"
70
101
  end
71
102
  end
72
103
 
73
104
  def copy_js(config)
74
- dest = File.join(@root, "build/edgeway")
75
- FileUtils.mkdir_p(dest)
76
- Dir.glob(File.join(CLI.gem_dir, "js/*.js")).each { |f| FileUtils.cp(f, dest) }
77
- js = "// Generated by `edgeway build` from config/database.yml; do not edit.\n" \
78
- "export default #{JSON.generate({ "env" => config.env, "d1Binding" => config.binding_name })};\n"
79
- File.write(File.join(dest, "config.js"), js)
105
+ JsFiles.copy(File.join(@root, "build/edgeway"), config_js(config))
106
+ end
107
+
108
+ def config_js(config)
109
+ values = { "env" => config.env, "d1Binding" => config.binding_name, "sessions" => config.sessions }
110
+ JsFiles.config_js(values, "build")
80
111
  end
81
112
 
82
113
  def compile
83
114
  spinel = Toolchain.spinel || raise(Error, "no Spinel compiler found (install it with mise, or set SPINEL=/path/to/spinel)")
84
115
  cmd = [spinel, "-I", File.join(CLI.gem_dir, "lib"), "--target=wasm32-wasi", "build/main.rb", "-o", "build/app.wasm"]
116
+ cmd << "--incremental" unless @release
85
117
  puts cmd.join(" ")
86
118
  system(*cmd, chdir: @root) || raise(Error, "spinel failed")
87
119
  end
@@ -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