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,68 @@
|
|
|
1
|
+
// X-D1-Bookmark is "BINDING=bookmark, ..."; clients resend it whole.
|
|
2
|
+
import { addVary } from "./headers.js";
|
|
3
|
+
|
|
4
|
+
export const BOOKMARK_HEADER = "X-D1-Bookmark";
|
|
5
|
+
|
|
6
|
+
const isRead = (request) => request?.method === "GET" || request?.method === "HEAD";
|
|
7
|
+
|
|
8
|
+
// A bookmark is opaque and D1's: one that does not look like D1's is dropped,
|
|
9
|
+
// as D1 itself would keep a high-sorting garbage value as the latest bookmark.
|
|
10
|
+
const BINDING_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
11
|
+
const BOOKMARK = /^[0-9a-f-]{1,128}$/;
|
|
12
|
+
|
|
13
|
+
export function parseBookmarks(value) {
|
|
14
|
+
const bookmarks = {};
|
|
15
|
+
for (const pair of (value ?? "").split(",")) {
|
|
16
|
+
const separator = pair.indexOf("=");
|
|
17
|
+
const name = pair.slice(0, separator).trim();
|
|
18
|
+
const bookmark = pair.slice(separator + 1).trim();
|
|
19
|
+
if (separator > 0 && BINDING_NAME.test(name) && BOOKMARK.test(bookmark)) bookmarks[name] = bookmark;
|
|
20
|
+
}
|
|
21
|
+
return bookmarks;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class D1Sessions {
|
|
25
|
+
constructor(env, request, names = []) {
|
|
26
|
+
this.env = env;
|
|
27
|
+
this.names = new Set(names);
|
|
28
|
+
this.readRequest = isRead(request);
|
|
29
|
+
this.bookmarks = parseBookmarks(request?.headers.get(BOOKMARK_HEADER));
|
|
30
|
+
this.sessions = new Map();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
uses(name) {
|
|
34
|
+
return this.names.has(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
database(name) {
|
|
38
|
+
const binding = this.env[name];
|
|
39
|
+
if (!binding || !this.names.has(name)) return binding;
|
|
40
|
+
if (!this.sessions.has(name)) this.sessions.set(name, this.start(binding, name));
|
|
41
|
+
return this.sessions.get(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
start(binding, name) {
|
|
45
|
+
return binding.withSession(this.startPoint(name));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
startPoint(name) {
|
|
49
|
+
if (!this.readRequest) return "first-primary";
|
|
50
|
+
return this.bookmarks[name] ?? "first-unconstrained";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
header() {
|
|
54
|
+
const pairs = [];
|
|
55
|
+
for (const name of this.names) {
|
|
56
|
+
const bookmark = this.sessions.get(name)?.getBookmark() ?? this.bookmarks[name];
|
|
57
|
+
if (bookmark) pairs.push(`${name}=${bookmark}`);
|
|
58
|
+
}
|
|
59
|
+
return pairs.join(", ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
annotate(response) {
|
|
63
|
+
const value = this.header();
|
|
64
|
+
if (value) response.headers.set(BOOKMARK_HEADER, value);
|
|
65
|
+
if (this.names.size > 0) addVary(response.headers, BOOKMARK_HEADER);
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
}
|
data/js/cloudflare-kv.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jspiHelpers } from "./jspi.js";
|
|
2
|
+
|
|
3
|
+
export const MIN_EXPIRATION_TTL = 60;
|
|
4
|
+
|
|
5
|
+
export function putOptions(expiresIn) {
|
|
6
|
+
if (!(expiresIn > 0)) return undefined;
|
|
7
|
+
return { expirationTtl: Math.max(expiresIn, MIN_EXPIRATION_TTL) };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// An R2 bucket also has get, put and delete; only a KV namespace has getWithMetadata.
|
|
11
|
+
export const isKvNamespace = (value) => Boolean(value) && ["get", "put", "delete", "getWithMetadata"].every((name) => typeof value[name] === "function");
|
|
12
|
+
|
|
13
|
+
export function kvImports(env, getInstance) {
|
|
14
|
+
const { readCString, setResult, take } = jspiHelpers(getInstance);
|
|
15
|
+
|
|
16
|
+
const namespaceOf = (bindingName) => {
|
|
17
|
+
const namespace = env[bindingName];
|
|
18
|
+
if (!isKvNamespace(namespace)) throw new Error(`no KV binding ${bindingName}`);
|
|
19
|
+
return namespace;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const suspending = (operation) => new WebAssembly.Suspending(async (bindingPointer, keyPointer, ...rest) => {
|
|
23
|
+
let result;
|
|
24
|
+
try {
|
|
25
|
+
const namespace = namespaceOf(readCString(bindingPointer));
|
|
26
|
+
result = await operation(namespace, readCString(keyPointer), ...rest);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
result = { error: String(error?.message ?? error) };
|
|
29
|
+
}
|
|
30
|
+
return setResult(result);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
edgeway_kv: {
|
|
35
|
+
get: suspending(async (namespace, key) => ({ value: await namespace.get(key, "text") })),
|
|
36
|
+
put: suspending(async (namespace, key, textPointer, expiresIn) => {
|
|
37
|
+
await namespace.put(key, readCString(textPointer), putOptions(expiresIn));
|
|
38
|
+
return { ok: true };
|
|
39
|
+
}),
|
|
40
|
+
delete: suspending(async (namespace, key) => {
|
|
41
|
+
await namespace.delete(key);
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}),
|
|
44
|
+
take,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jspiHelpers } from "./jspi.js";
|
|
2
|
+
|
|
3
|
+
const isRateLimit = (value) => Boolean(value) && typeof value.limit === "function";
|
|
4
|
+
|
|
5
|
+
export function rateLimitImports(env, getInstance) {
|
|
6
|
+
const { readCString, setResult, take } = jspiHelpers(getInstance);
|
|
7
|
+
|
|
8
|
+
const check = new WebAssembly.Suspending(async (bindingPointer, keyPointer) => {
|
|
9
|
+
let result;
|
|
10
|
+
try {
|
|
11
|
+
const bindingName = readCString(bindingPointer);
|
|
12
|
+
const rateLimit = env[bindingName];
|
|
13
|
+
if (isRateLimit(rateLimit)) {
|
|
14
|
+
const { success } = await rateLimit.limit({ key: readCString(keyPointer) });
|
|
15
|
+
result = { success: success !== false };
|
|
16
|
+
} else {
|
|
17
|
+
result = { misconfigured: `no rate limit binding ${bindingName}` };
|
|
18
|
+
}
|
|
19
|
+
} catch (error) {
|
|
20
|
+
result = { error: String(error?.message ?? error) };
|
|
21
|
+
}
|
|
22
|
+
return setResult(result);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
edgeway_ratelimit: {
|
|
27
|
+
check,
|
|
28
|
+
take,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The databases of `edgeway test` on a local D1: dropped and migrated again
|
|
2
|
+
// before each test, so every test starts from the migrations' state.
|
|
3
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// What wrangler and D1 keep in the database themselves.
|
|
7
|
+
const KEPT_OBJECT = /^(sqlite_|_cf_)|^d1_migrations$/;
|
|
8
|
+
|
|
9
|
+
const quoted = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
10
|
+
|
|
11
|
+
const COMMENT = /--[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
12
|
+
const withoutComments = (sql) => sql.replace(COMMENT, " ");
|
|
13
|
+
const startsTrigger = (statement) => /^\s*create\s+(temp(orary)?\s+)?trigger\b/i.test(withoutComments(statement));
|
|
14
|
+
// A trigger's body is statements ending in ';', and the trigger ends at the
|
|
15
|
+
// END after the last of them (sqlite3_complete's rule): not at the END of a
|
|
16
|
+
// CASE, nor a column named end.
|
|
17
|
+
const triggerEnded = (statement) => /;\s*end\s*$/i.test(withoutComments(statement));
|
|
18
|
+
|
|
19
|
+
// SQL text split into statements at the ';' outside strings, comments and a
|
|
20
|
+
// trigger's body. Each statement is run with prepare(), which takes one.
|
|
21
|
+
// Text with nothing but comments is no statement (D1 refuses it).
|
|
22
|
+
export function splitStatements(sql) {
|
|
23
|
+
const statements = [];
|
|
24
|
+
let current = "";
|
|
25
|
+
let at = 0;
|
|
26
|
+
const copyUntil = (terminator) => {
|
|
27
|
+
const stop = sql.indexOf(terminator, at + 1);
|
|
28
|
+
const end = stop === -1 ? sql.length : stop + terminator.length;
|
|
29
|
+
current += sql.slice(at, end);
|
|
30
|
+
at = end;
|
|
31
|
+
};
|
|
32
|
+
const endsStatement = () => !startsTrigger(current) || triggerEnded(current);
|
|
33
|
+
while (at < sql.length) {
|
|
34
|
+
const char = sql[at];
|
|
35
|
+
if (char === "'" || char === '"' || char === "`") copyUntil(char);
|
|
36
|
+
else if (char === "[") copyUntil("]");
|
|
37
|
+
else if (sql.startsWith("--", at)) copyUntil("\n");
|
|
38
|
+
else if (sql.startsWith("/*", at)) copyUntil("*/");
|
|
39
|
+
else if (char === ";" && endsStatement()) {
|
|
40
|
+
statements.push(current.trim());
|
|
41
|
+
current = "";
|
|
42
|
+
at++;
|
|
43
|
+
} else {
|
|
44
|
+
current += char;
|
|
45
|
+
at++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
statements.push(current.trim());
|
|
49
|
+
return statements.filter((statement) => withoutComments(statement).trim() !== "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The files of a migrations directory in file name order, each as
|
|
53
|
+
// { name, statements }. A directory that is not there has none.
|
|
54
|
+
export function migrationFiles(directory, { readdir = readdirSync, read = readFileSync, exists = existsSync } = {}) {
|
|
55
|
+
if (!exists(directory)) return [];
|
|
56
|
+
return readdir(directory)
|
|
57
|
+
.filter((name) => name.endsWith(".sql"))
|
|
58
|
+
.sort()
|
|
59
|
+
.map((name) => ({ name, statements: splitStatements(read(join(directory, name), "utf8")) }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// databases: [{ binding, migrations: directory }]
|
|
63
|
+
export function loadMigrations(databases, options) {
|
|
64
|
+
return databases.map(({ binding, migrations }) => ({ binding, files: migrationFiles(migrations, options) }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The statements that empty a database, from the rows of sqlite_master
|
|
68
|
+
// ({ type, name }). Dropping a table drops its indexes and triggers. Foreign
|
|
69
|
+
// keys are enforced, so the checks wait for the end of the batch.
|
|
70
|
+
export function dropStatements(objects) {
|
|
71
|
+
const dropped = objects.filter(({ name }) => !KEPT_OBJECT.test(name));
|
|
72
|
+
const views = dropped.filter(({ type }) => type === "view");
|
|
73
|
+
const tables = dropped.filter(({ type }) => type === "table");
|
|
74
|
+
if (views.length + tables.length === 0) return [];
|
|
75
|
+
return [
|
|
76
|
+
"PRAGMA defer_foreign_keys = on",
|
|
77
|
+
...views.map(({ name }) => `DROP VIEW IF EXISTS ${quoted(name)}`),
|
|
78
|
+
...tables.map(({ name }) => `DROP TABLE IF EXISTS ${quoted(name)}`),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function dropEverything(database) {
|
|
83
|
+
const { results } = await database.prepare("SELECT type, name FROM sqlite_master WHERE type IN ('table', 'view')").all();
|
|
84
|
+
const statements = dropStatements(results);
|
|
85
|
+
if (statements.length > 0) await database.batch(statements.map((sql) => database.prepare(sql)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function migrate(database, files) {
|
|
89
|
+
for (const { name, statements } of files) {
|
|
90
|
+
if (statements.length === 0) continue;
|
|
91
|
+
try {
|
|
92
|
+
await database.batch(statements.map((sql) => database.prepare(sql)));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(`migration ${name}: ${error?.message ?? error}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// migrations: loadMigrations' answer. Entries that share a binding are one
|
|
100
|
+
// database: it is emptied once, then every entry's files are applied in order.
|
|
101
|
+
export async function resetDatabases(env, migrations) {
|
|
102
|
+
const filesByBinding = new Map();
|
|
103
|
+
for (const { binding, files } of migrations) {
|
|
104
|
+
filesByBinding.set(binding, [...(filesByBinding.get(binding) ?? []), ...files]);
|
|
105
|
+
}
|
|
106
|
+
for (const [binding, files] of filesByBinding) {
|
|
107
|
+
const database = env[binding];
|
|
108
|
+
if (!database) throw new Error(`no D1 binding ${binding} in wrangler.toml`);
|
|
109
|
+
await dropEverything(database);
|
|
110
|
+
await migrate(database, files);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Runs the wasm build of an app's tests, on a local in-memory D1:
|
|
2
|
+
//
|
|
3
|
+
// node cloudflare-test-runner.js TEST.wasm [ARGS...]
|
|
4
|
+
//
|
|
5
|
+
// `edgeway test` starts it (with --experimental-wasm-jspi and --experimental-wasm-exnref on Node.js 24); ARGS
|
|
6
|
+
// go to the program (-n NAME). The environment is wrangler's getPlatformProxy
|
|
7
|
+
// with persist: false, so the state in .wrangler/state is never touched. The
|
|
8
|
+
// wasm calls edgeway_test.reset before each test, and the exit status is its own.
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import config from "./config.js";
|
|
12
|
+
import { WASI } from "./wasi-shim.js";
|
|
13
|
+
import { lineSplitter, parseLogLine } from "./runtime.js";
|
|
14
|
+
import { d1Imports, varsOf } from "./cloudflare.js";
|
|
15
|
+
import { jwtImports } from "./jwt.js";
|
|
16
|
+
import { fetchImports } from "./fetch.js";
|
|
17
|
+
import { kvImports } from "./cloudflare-kv.js";
|
|
18
|
+
import { rateLimitImports } from "./cloudflare-ratelimit.js";
|
|
19
|
+
import { D1Sessions } from "./cloudflare-d1-sessions.js";
|
|
20
|
+
import { loadMigrations, resetDatabases } from "./cloudflare-test-database.js";
|
|
21
|
+
|
|
22
|
+
function requireJspi() {
|
|
23
|
+
if (typeof WebAssembly.Suspending === "function") return;
|
|
24
|
+
throw new Error("this Node.js has no WebAssembly JSPI; Node.js 24 needs --experimental-wasm-jspi (`edgeway test` passes it)");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function logText(line) {
|
|
28
|
+
const { level, message, ...fields } = parseLogLine(line) ?? {};
|
|
29
|
+
if (level === undefined) return line;
|
|
30
|
+
const pairs = Object.entries(fields).map(([name, value]) => ` ${name}=${typeof value === "string" ? value : JSON.stringify(value)}`);
|
|
31
|
+
return `[edgeway] ${level.toUpperCase()} ${message}${pairs.join("")}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runTests(wasmPath, args, env) {
|
|
35
|
+
const migrations = loadMigrations(config.databases);
|
|
36
|
+
const stderr = lineSplitter((line) => process.stderr.write(`${logText(line)}\n`));
|
|
37
|
+
const wasi = new WASI({
|
|
38
|
+
args: ["test", ...args],
|
|
39
|
+
env: { ...varsOf(env), EDGEWAY_ENV: config.env },
|
|
40
|
+
onStdout: (text) => process.stdout.write(text),
|
|
41
|
+
onStderr: (text) => stderr.push(text),
|
|
42
|
+
});
|
|
43
|
+
const reset = new WebAssembly.Suspending(() => resetDatabases(env, migrations));
|
|
44
|
+
let instance;
|
|
45
|
+
instance = new WebAssembly.Instance(await WebAssembly.compile(readFileSync(wasmPath)), {
|
|
46
|
+
wasi_snapshot_preview1: wasi.wasiImport,
|
|
47
|
+
...d1Imports(env, () => instance, new D1Sessions(env, undefined, config.sessions)),
|
|
48
|
+
...jwtImports(env, () => instance),
|
|
49
|
+
...fetchImports(() => instance),
|
|
50
|
+
...kvImports(env, () => instance),
|
|
51
|
+
...rateLimitImports(env, () => instance),
|
|
52
|
+
edgeway_test: { reset },
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
return await wasi.startAsync(instance);
|
|
56
|
+
} finally {
|
|
57
|
+
stderr.flush();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function main() {
|
|
62
|
+
const [wasmPath, ...args] = process.argv.slice(2);
|
|
63
|
+
requireJspi();
|
|
64
|
+
const { getPlatformProxy } = await import("wrangler");
|
|
65
|
+
const proxy = await getPlatformProxy({ configPath: join(config.appRoot, "wrangler.toml"), persist: false });
|
|
66
|
+
try {
|
|
67
|
+
process.exitCode = await runTests(wasmPath, args, proxy.env);
|
|
68
|
+
} finally {
|
|
69
|
+
await proxy.dispose();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
main().catch((error) => {
|
|
74
|
+
console.error(`edgeway test: ${error instanceof WebAssembly.RuntimeError ? error.stack : error?.message ?? error}`);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
});
|
data/js/cloudflare.js
CHANGED
|
@@ -5,20 +5,104 @@
|
|
|
5
5
|
// export default createWorker(app);
|
|
6
6
|
//
|
|
7
7
|
// build/edgeway/config.js is written by `edgeway build` from
|
|
8
|
-
// config/database.yml: the environment name
|
|
8
|
+
// config/database.yml: the environment name, the D1 binding, and the bindings
|
|
9
|
+
// that use D1 Sessions.
|
|
9
10
|
import { runCgi } from "./runtime.js";
|
|
10
11
|
import config from "./config.js";
|
|
11
12
|
import { jwtImports } from "./jwt.js";
|
|
13
|
+
import { fetchImports } from "./fetch.js";
|
|
14
|
+
import { kvImports } from "./cloudflare-kv.js";
|
|
15
|
+
import { rateLimitImports } from "./cloudflare-ratelimit.js";
|
|
12
16
|
import { withCors } from "./cors.js";
|
|
17
|
+
import { BOOKMARK_HEADER, D1Sessions } from "./cloudflare-d1-sessions.js";
|
|
18
|
+
|
|
19
|
+
// D1's transient errors, which are worth a retry by the client
|
|
20
|
+
// (developers.cloudflare.com/d1/observability/debug-d1/). D1 retries read-only
|
|
21
|
+
// queries itself; Edgeway never retries, as a write is not idempotent.
|
|
22
|
+
// "exceeded", "CPU time limit", "memory limit" and "timeout" are not here.
|
|
23
|
+
export const TRANSIENT_D1_ERRORS = [
|
|
24
|
+
"Network connection lost",
|
|
25
|
+
"reset because its code was updated",
|
|
26
|
+
"storage caused object to be reset",
|
|
27
|
+
"Replica disconnected from primary",
|
|
28
|
+
"transient issue on remote node",
|
|
29
|
+
"Can't read from request stream because client disconnected",
|
|
30
|
+
"D1 DB is overloaded",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export const isTransientD1Error = (message) => TRANSIENT_D1_ERRORS.some((s) => message.includes(s));
|
|
34
|
+
|
|
35
|
+
// Counts are 32-bit in wasm: a larger one is clamped.
|
|
36
|
+
const MAX_COUNT = 2147483647;
|
|
37
|
+
|
|
38
|
+
// D1's result.meta in the shape of META above. A local D1 (wrangler dev) may
|
|
39
|
+
// leave fields out.
|
|
40
|
+
export function metaOf(meta) {
|
|
41
|
+
return {
|
|
42
|
+
rows_read: Math.min(meta?.rows_read ?? 0, MAX_COUNT),
|
|
43
|
+
rows_written: Math.min(meta?.rows_written ?? 0, MAX_COUNT),
|
|
44
|
+
duration: meta?.duration ?? 0,
|
|
45
|
+
served_by_region: meta?.served_by_region ?? "",
|
|
46
|
+
served_by_primary: meta?.served_by_primary ?? true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sumMeta(metas) {
|
|
51
|
+
const all = metas.map(metaOf);
|
|
52
|
+
const sum = (field) => all.reduce((total, meta) => total + meta[field], 0);
|
|
53
|
+
return {
|
|
54
|
+
...all[all.length - 1],
|
|
55
|
+
rows_read: Math.min(sum("rows_read"), MAX_COUNT),
|
|
56
|
+
rows_written: Math.min(sum("rows_written"), MAX_COUNT),
|
|
57
|
+
duration: sum("duration"),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A BLOB crosses as {"$blob": "<base64>"}. D1 binds an ArrayBuffer as a BLOB and
|
|
62
|
+
// answers a BLOB column as an Array of byte numbers.
|
|
63
|
+
export function bindOf(value) {
|
|
64
|
+
if (value === null || typeof value !== "object" || typeof value.$blob !== "string") return value;
|
|
65
|
+
return Uint8Array.from(atob(value.$blob), (char) => char.charCodeAt(0)).buffer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isBytes = (value) => Array.isArray(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
69
|
+
|
|
70
|
+
function bytesOf(value) {
|
|
71
|
+
if (Array.isArray(value)) return Uint8Array.from(value);
|
|
72
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
73
|
+
return new Uint8Array(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function base64Of(value) {
|
|
77
|
+
const bytes = bytesOf(value);
|
|
78
|
+
let binary = "";
|
|
79
|
+
for (let at = 0; at < bytes.length; at += 8192) binary += String.fromCharCode(...bytes.subarray(at, at + 8192));
|
|
80
|
+
return btoa(binary);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function rowOf(row) {
|
|
84
|
+
for (const column of Object.keys(row)) {
|
|
85
|
+
const value = row[column];
|
|
86
|
+
if (value !== null && typeof value === "object" && isBytes(value)) row[column] = { $blob: base64Of(value) };
|
|
87
|
+
}
|
|
88
|
+
return row;
|
|
89
|
+
}
|
|
13
90
|
|
|
14
91
|
// The wasm import module "edgeway_d1" (lib/edgeway/service/cloudflare/d1.rb). Each call
|
|
15
92
|
// answers the byte length of its JSON result; take() copies it out.
|
|
16
|
-
// query(binding, sql, binds) -> {"rows": [...]}
|
|
17
|
-
// execute(binding, sql, binds) -> {"changes": n, "last_insert_id": id}
|
|
18
|
-
//
|
|
93
|
+
// query(binding, sql, binds) -> {"rows": [...], "meta": META}
|
|
94
|
+
// execute(binding, sql, binds) -> {"changes": n, "last_insert_id": id, "meta": META}
|
|
95
|
+
// exec(binding, sql) -> {"count": n, "meta": META}
|
|
96
|
+
// (sql: statements separated by newlines, no binds; an error on a database with sessions)
|
|
97
|
+
// batch(binding, statements) -> {"results": [{"changes": n, "last_insert_id": id}, ...], "meta": META}
|
|
19
98
|
// (statements: [[sql, binds], ...], one atomic D1 batch)
|
|
20
|
-
//
|
|
21
|
-
|
|
99
|
+
// META is {"rows_read": n, "rows_written": n, "duration": ms, "served_by_region": "...",
|
|
100
|
+
// "served_by_primary": bool}; a batch's sums the statements'. exec's has only
|
|
101
|
+
// rows_read, rows_written and duration.
|
|
102
|
+
// An error is {"error": "message"} (no meta), with "unavailable": true when it is transient.
|
|
103
|
+
const SESSION_EXEC_ERROR = "exec() is not available on a database with sessions: true (a D1 session has no exec)";
|
|
104
|
+
|
|
105
|
+
export function d1Imports(env, getInstance, sessions = new D1Sessions(env)) {
|
|
22
106
|
const mem = () => new Uint8Array(getInstance().exports.memory.buffer);
|
|
23
107
|
const cstr = (p) => {
|
|
24
108
|
const b = mem();
|
|
@@ -29,36 +113,47 @@ export function d1Imports(env, getInstance) {
|
|
|
29
113
|
let pending = new Uint8Array(0);
|
|
30
114
|
|
|
31
115
|
// fn(db, ...pointers) answers the result object; a failure is {error}.
|
|
32
|
-
const suspending = (fn) => new WebAssembly.Suspending(async (bindingPtr, ...ptrs) => {
|
|
116
|
+
const suspending = (fn, { withoutSessions = false } = {}) => new WebAssembly.Suspending(async (bindingPtr, ...ptrs) => {
|
|
33
117
|
let res;
|
|
34
118
|
try {
|
|
35
119
|
const name = cstr(bindingPtr) || config.d1Binding;
|
|
36
|
-
|
|
120
|
+
if (withoutSessions && sessions.uses(name)) throw new Error(SESSION_EXEC_ERROR);
|
|
121
|
+
const db = sessions.database(name);
|
|
37
122
|
if (!db) throw new Error(`no D1 binding ${name}`);
|
|
38
123
|
res = await fn(db, ...ptrs);
|
|
39
124
|
} catch (e) {
|
|
40
|
-
|
|
125
|
+
const error = String(e?.message ?? e);
|
|
126
|
+
res = isTransientD1Error(error) ? { error, unavailable: true } : { error };
|
|
41
127
|
}
|
|
42
128
|
pending = new TextEncoder().encode(JSON.stringify(res));
|
|
43
129
|
return pending.length;
|
|
44
130
|
});
|
|
131
|
+
const prepare = (db, sql, binds) => db.prepare(sql).bind(...binds.map(bindOf));
|
|
45
132
|
const run = (fn) => suspending((db, sqlPtr, bindsPtr) =>
|
|
46
|
-
fn(
|
|
133
|
+
fn(prepare(db, cstr(sqlPtr), JSON.parse(cstr(bindsPtr)))));
|
|
47
134
|
|
|
48
135
|
return {
|
|
49
136
|
edgeway_d1: {
|
|
50
|
-
query: run(async (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
137
|
+
query: run(async (statement) => {
|
|
138
|
+
const result = await statement.all();
|
|
139
|
+
return { rows: result.results.map(rowOf), meta: metaOf(result.meta) };
|
|
140
|
+
}),
|
|
141
|
+
execute: run(async (statement) => {
|
|
142
|
+
const meta = (await statement.run()).meta;
|
|
143
|
+
return { changes: meta.changes ?? 0, last_insert_id: meta.last_row_id ?? 0, meta: metaOf(meta) };
|
|
54
144
|
}),
|
|
145
|
+
exec: suspending(async (db, sqlPtr) => {
|
|
146
|
+
const result = await db.exec(cstr(sqlPtr));
|
|
147
|
+
return { count: result.count ?? 0, meta: { rows_read: 0, rows_written: 0, duration: result.duration ?? 0 } };
|
|
148
|
+
}, { withoutSessions: true }),
|
|
55
149
|
// db.batch() is one transaction: a failing statement rejects it and
|
|
56
150
|
// none of the statements stay applied.
|
|
57
151
|
batch: suspending(async (db, statementsPtr) => {
|
|
58
152
|
const statements = JSON.parse(cstr(statementsPtr));
|
|
59
|
-
const results = await db.batch(statements.map(([sql, binds]) =>
|
|
153
|
+
const results = await db.batch(statements.map(([sql, binds]) => prepare(db, sql, binds)));
|
|
60
154
|
return {
|
|
61
|
-
results: results.map((
|
|
155
|
+
results: results.map((result) => ({ changes: result.meta.changes ?? 0, last_insert_id: result.meta.last_row_id ?? 0 })),
|
|
156
|
+
meta: sumMeta(results.map((result) => result.meta)),
|
|
62
157
|
};
|
|
63
158
|
}),
|
|
64
159
|
take(buf) {
|
|
@@ -73,6 +168,7 @@ export function d1Imports(env, getInstance) {
|
|
|
73
168
|
// out.
|
|
74
169
|
const FRAMEWORK_VARS = new Set([
|
|
75
170
|
"JWT_ISSUER", "JWT_AUDIENCE", "JWT_LEEWAY", "JWT_ALGORITHMS", "JWT_JWKS_URL",
|
|
171
|
+
"LOG_LEVEL",
|
|
76
172
|
]);
|
|
77
173
|
|
|
78
174
|
// The Worker's variables (strings) become CGI environment variables, which
|
|
@@ -90,14 +186,40 @@ export function varsOf(env) {
|
|
|
90
186
|
return vars;
|
|
91
187
|
}
|
|
92
188
|
|
|
189
|
+
// Not CORS-safelisted, so a page reads it on a 429 or 503 only when exposed.
|
|
190
|
+
const RETRY_AFTER_HEADER = "Retry-After";
|
|
191
|
+
|
|
192
|
+
export function corsOptions(sessionBindings) {
|
|
193
|
+
const bookmark = sessionBindings.length > 0 ? [BOOKMARK_HEADER] : [];
|
|
194
|
+
return { exposeHeaders: [RETRY_AFTER_HEADER, ...bookmark], allowHeaders: bookmark };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// What identifies the request in every log line: the ray id (absent in local
|
|
198
|
+
// development).
|
|
199
|
+
export function logFieldsOf(request) {
|
|
200
|
+
const ray = request.headers.get("cf-ray");
|
|
201
|
+
return ray ? { ray } : {};
|
|
202
|
+
}
|
|
203
|
+
|
|
93
204
|
// appModule: the WebAssembly.Module that `import app from "...app.wasm"` gives.
|
|
94
205
|
export function createWorker(appModule) {
|
|
95
206
|
return {
|
|
96
|
-
fetch: withCors((request, env) =>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
207
|
+
fetch: withCors(async (request, env) => {
|
|
208
|
+
const sessions = new D1Sessions(env, request, config.sessions ?? []);
|
|
209
|
+
const response = await runCgi(appModule, request, {
|
|
210
|
+
extraEnv: { EDGEWAY_ENV: config.env, ...varsOf(env) },
|
|
211
|
+
logFields: logFieldsOf(request),
|
|
212
|
+
// Every module is always supplied, whether or not the app uses it:
|
|
213
|
+
// the wasm instance needs every import it declares.
|
|
214
|
+
imports: (getInstance) => ({
|
|
215
|
+
...d1Imports(env, getInstance, sessions),
|
|
216
|
+
...jwtImports(env, getInstance),
|
|
217
|
+
...fetchImports(getInstance),
|
|
218
|
+
...kvImports(env, getInstance),
|
|
219
|
+
...rateLimitImports(env, getInstance),
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
return sessions.annotate(response);
|
|
223
|
+
}, corsOptions(config.sessions ?? [])),
|
|
102
224
|
};
|
|
103
225
|
}
|
data/js/cors.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// status instead of an opaque CORS error. Nothing here is specific to a
|
|
5
5
|
// platform. Cookies are not supported: Access-Control-Allow-Credentials is
|
|
6
6
|
// never sent.
|
|
7
|
+
import { addVary, withHeaders } from "./headers.js";
|
|
8
|
+
|
|
7
9
|
const DEFAULT_HEADERS = "Authorization, Content-Type";
|
|
8
10
|
const DEFAULT_MAX_AGE = "600";
|
|
9
11
|
const METHODS = "GET, HEAD, POST, PUT, PATCH, DELETE";
|
|
@@ -15,17 +17,17 @@ function json(status, body, headers = {}) {
|
|
|
15
17
|
// The settings from the Worker's variables, or null when CORS is off.
|
|
16
18
|
// A bad CORS_MAX_AGE is { error }: like JWT, a misconfiguration is a 500, not
|
|
17
19
|
// a silent default.
|
|
18
|
-
function settings(env) {
|
|
19
|
-
const list = (env.CORS_ORIGINS ?? "").split(",").map((
|
|
20
|
+
function settings(env, { exposeHeaders, allowHeaders }) {
|
|
21
|
+
const list = (env.CORS_ORIGINS ?? "").split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
20
22
|
if (list.length === 0) return null;
|
|
21
23
|
const maxAge = (env.CORS_MAX_AGE ?? "").trim() || DEFAULT_MAX_AGE;
|
|
22
24
|
if (!/^\d+$/.test(maxAge)) return { error: `CORS_MAX_AGE must be a non-negative integer, got "${env.CORS_MAX_AGE}"` };
|
|
23
25
|
return {
|
|
24
26
|
any: list.includes("*"),
|
|
25
27
|
origins: new Set(list),
|
|
26
|
-
headers: (env.CORS_HEADERS ?? "").trim() || DEFAULT_HEADERS,
|
|
28
|
+
headers: withHeaders((env.CORS_HEADERS ?? "").trim() || DEFAULT_HEADERS, ...allowHeaders),
|
|
27
29
|
maxAge,
|
|
28
|
-
expose: (env.CORS_EXPOSE_HEADERS
|
|
30
|
+
expose: withHeaders(env.CORS_EXPOSE_HEADERS, ...exposeHeaders),
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -36,20 +38,17 @@ function allowed(cfg, origin) {
|
|
|
36
38
|
return cfg.any || cfg.origins.has(origin);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (have.includes("*")) return;
|
|
43
|
-
for (const n of names) {
|
|
44
|
-
if (!have.some((h) => h.toLowerCase() === n.toLowerCase())) have.push(n);
|
|
45
|
-
}
|
|
46
|
-
headers.set("Vary", have.join(", "));
|
|
41
|
+
function exposeOn(headers, expose) {
|
|
42
|
+
const merged = withHeaders(headers.get("Access-Control-Expose-Headers"), ...expose.split(", ").filter(Boolean));
|
|
43
|
+
if (merged) headers.set("Access-Control-Expose-Headers", merged);
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
// Wraps a fetch handler (request, env, ctx) -> Response.
|
|
50
|
-
|
|
46
|
+
// Wraps a fetch handler (request, env, ctx) -> Response. exposeHeaders and
|
|
47
|
+
// allowHeaders are names the platform layer always adds to the exposed and the
|
|
48
|
+
// preflight-allowed headers.
|
|
49
|
+
export function withCors(handler, { exposeHeaders = [], allowHeaders = [] } = {}) {
|
|
51
50
|
return async (request, env, ctx) => {
|
|
52
|
-
const cfg = settings(env);
|
|
51
|
+
const cfg = settings(env, { exposeHeaders, allowHeaders });
|
|
53
52
|
if (!cfg) return handler(request, env, ctx);
|
|
54
53
|
if (cfg.error) {
|
|
55
54
|
console.error(cfg.error);
|
|
@@ -79,7 +78,7 @@ export function withCors(handler) {
|
|
|
79
78
|
const out = new Response(res.body, res);
|
|
80
79
|
if (ok) {
|
|
81
80
|
out.headers.set("Access-Control-Allow-Origin", origin);
|
|
82
|
-
|
|
81
|
+
exposeOn(out.headers, cfg.expose);
|
|
83
82
|
}
|
|
84
83
|
addVary(out.headers, "Origin");
|
|
85
84
|
return out;
|