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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +784 -125
- data/js/cloudflare-d1-sessions.js +68 -0
- 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 +144 -22
- data/js/cors.js +15 -16
- data/js/fetch.js +113 -0
- data/js/headers.js +16 -0
- data/js/jspi.js +32 -0
- data/js/jwt.js +6 -40
- data/js/runtime.js +42 -5
- data/lib/edgeway/application.rb +21 -9
- 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/app_config.rb +39 -0
- data/lib/edgeway/cli/build/app_files.rb +40 -0
- data/lib/edgeway/cli/build/cache_config.rb +108 -0
- data/lib/edgeway/cli/build/database_config.rb +96 -0
- data/lib/edgeway/cli/build/js_files.rb +30 -0
- data/lib/edgeway/cli/build/view_compiler.rb +191 -0
- data/lib/edgeway/cli/build.rb +50 -18
- 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/generate.rb +174 -26
- data/lib/edgeway/cli/new.rb +1 -1
- data/lib/edgeway/cli/test.rb +28 -9
- data/lib/edgeway/cli/toolchain.rb +5 -0
- data/lib/edgeway/cli.rb +7 -4
- data/lib/edgeway/controller/base.rb +32 -3
- data/lib/edgeway/controller/caching.rb +140 -0
- data/lib/edgeway/controller/callbacks.rb +5 -1
- data/lib/edgeway/controller/dispatcher.rb +11 -1
- data/lib/edgeway/controller/rate_limiting.rb +145 -0
- data/lib/edgeway/controller/rendering.rb +24 -4
- data/lib/edgeway/db/adapter.rb +38 -0
- data/lib/edgeway/db/memory.rb +9 -2
- data/lib/edgeway/db/query_stats.rb +42 -0
- data/lib/edgeway/db/sqlite.rb +73 -9
- data/lib/edgeway/errors.rb +43 -0
- data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
- data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
- data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
- data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
- data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
- data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
- data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
- data/lib/edgeway/http/auth/stub.rb +1 -3
- data/lib/edgeway/http/client.rb +80 -0
- data/lib/edgeway/http/request.rb +7 -22
- data/lib/edgeway/http/stub_transport.rb +38 -0
- data/lib/edgeway/inflector.rb +19 -0
- data/lib/edgeway/logger.rb +33 -5
- data/lib/edgeway/model/attributes.rb +93 -10
- data/lib/edgeway/model/base.rb +50 -5
- data/lib/edgeway/model/connection_registry.rb +25 -0
- data/lib/edgeway/model/macros.rb +5 -1
- data/lib/edgeway/model/persistence.rb +18 -11
- data/lib/edgeway/model/relation.rb +106 -56
- data/lib/edgeway/model/validations.rb +29 -23
- 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/routing/route.rb +1 -6
- data/lib/edgeway/server.rb +13 -0
- data/lib/edgeway/service/cloudflare/d1.rb +44 -30
- 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 +86 -26
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway/view.rb +24 -0
- data/lib/edgeway.rb +9 -0
- metadata +50 -4
- data/lib/edgeway/cli/app_config.rb +0 -35
- 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
|
data/lib/edgeway/cli/build.rb
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
|
54
|
-
# 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.
|
|
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
|
-
|
|
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
|
|
69
|
-
else "Edgeway::
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|