toiljs 0.0.16 → 0.0.19
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.
- package/CHANGELOG.md +111 -0
- package/README.md +313 -128
- package/as-pect.config.js +1 -1
- package/build/backend/.tsbuildinfo +1 -1
- package/build/backend/index.d.ts +1 -0
- package/build/backend/index.js +20 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +1320 -696
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/devtools.js +42 -5
- package/build/client/errors.d.ts +1 -0
- package/build/client/errors.js +3 -0
- package/build/client/index.d.ts +2 -0
- package/build/client/index.js +2 -0
- package/build/client/rpc.d.ts +1 -0
- package/build/client/rpc.js +37 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.js +3 -1
- package/build/compiler/docs.js +62 -5
- package/build/compiler/generate.js +5 -4
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +1 -1
- package/build/compiler/plugin.js +80 -8
- package/build/compiler/seo.js +15 -1
- package/build/compiler/ssg.js +7 -1
- package/build/compiler/vite.js +25 -0
- package/build/io/.tsbuildinfo +1 -1
- package/build/io/codec.d.ts +54 -0
- package/build/io/codec.js +143 -0
- package/build/io/index.d.ts +1 -2
- package/build/io/index.js +1 -2
- package/eslint.config.js +1 -1
- package/examples/basic/client/routes/features/index.tsx +1 -1
- package/examples/basic/client/routes/io.tsx +6 -7
- package/examples/basic/client/routes/rest.tsx +74 -0
- package/examples/basic/client/routes/rpc.tsx +43 -0
- package/package.json +19 -7
- package/presets/prettier-plugin.js +51 -0
- package/presets/prettier.json +1 -0
- package/server/runtime/README.md +97 -0
- package/server/runtime/abort/abort.ts +27 -0
- package/server/runtime/env/Server.ts +61 -0
- package/server/runtime/envelope.ts +191 -0
- package/server/runtime/exports/index.ts +52 -0
- package/server/runtime/handlers/ToilHandler.ts +34 -0
- package/server/runtime/index.ts +26 -0
- package/server/runtime/lang/Potential.ts +5 -0
- package/server/runtime/memory.ts +81 -0
- package/server/runtime/request.ts +55 -0
- package/server/runtime/response.ts +86 -0
- package/server/runtime/rest/Rest.ts +39 -0
- package/server/runtime/rest/RestHandler.ts +20 -0
- package/server/runtime/rest/RouteContext.ts +82 -0
- package/server/runtime/rest/match.ts +48 -0
- package/server/runtime/tsconfig.json +7 -0
- package/src/backend/index.ts +45 -3
- package/src/cli/create.ts +15 -5
- package/src/cli/diagnostics.ts +81 -0
- package/src/cli/doctor.ts +384 -7
- package/src/cli/index.ts +11 -2
- package/src/client/dev/devtools.tsx +49 -4
- package/src/client/errors.ts +11 -0
- package/src/client/index.ts +2 -0
- package/src/client/rpc.ts +64 -0
- package/src/compiler/config.ts +3 -1
- package/src/compiler/docs.ts +62 -5
- package/src/compiler/generate.ts +6 -5
- package/src/compiler/index.ts +3 -1
- package/src/compiler/plugin.ts +99 -11
- package/src/compiler/seo.ts +23 -3
- package/src/compiler/ssg.ts +10 -1
- package/src/compiler/vite.ts +34 -0
- package/src/io/FastMap.ts +24 -0
- package/src/io/FastSet.ts +15 -1
- package/src/io/codec.ts +217 -0
- package/src/io/index.ts +1 -2
- package/src/io/types.ts +2 -1
- package/test/assembly/example.spec.ts +14 -4
- package/test/doctor.test.ts +65 -0
- package/test/errors.test.ts +21 -0
- package/test/io.test.ts +65 -41
- package/test/prettier-plugin.test.ts +46 -0
- package/test/rpc.test.ts +50 -0
- package/tests/data-parity/generated-parity.ts +99 -0
- package/tests/data-parity/parity.ts +80 -0
- package/tests/data-parity/spec.ts +46 -0
- package/tsconfig.json +1 -1
- package/tsconfig.server.json +1 -1
- package/build/io/BinaryReader.d.ts +0 -44
- package/build/io/BinaryReader.js +0 -244
- package/build/io/BinaryWriter.d.ts +0 -44
- package/build/io/BinaryWriter.js +0 -297
- package/build/server/release.wasm +0 -0
- package/build/server/release.wat +0 -9
- package/src/io/BinaryReader.ts +0 -340
- package/src/io/BinaryWriter.ts +0 -385
- package/src/server/index.ts +0 -10
- package/src/server/main.ts +0 -13
- package/src/server/tsconfig.json +0 -4
- package/toilconfig.json +0 -30
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Demo of the generated REST fetch client (see ../../shared/server.ts, emitted by the
|
|
2
|
+
// server build from the `@rest` controllers in server/api.ts). Unlike `Server.<service>`
|
|
3
|
+
// RPC, this is working code: `Server.REST.<controller>.<route>(args)` is a real, typed
|
|
4
|
+
// `fetch`. `args` is `{ params?, body?, query?, headers? }`; a `@data` return is parsed
|
|
5
|
+
// into its typed class, and a route that returns a `Response` hands you the raw fetch
|
|
6
|
+
// `Response` to inspect (status, `.json()`, ...). Needs the server running to respond.
|
|
7
|
+
import { useState } from 'react';
|
|
8
|
+
|
|
9
|
+
import { NewPlayer, type Player, ScoreDelta } from 'shared/server';
|
|
10
|
+
|
|
11
|
+
export default function RestDemo() {
|
|
12
|
+
const [log, setLog] = useState<string[]>([]);
|
|
13
|
+
const note = (line: string) => setLog((prev) => [line, ...prev].slice(0, 8));
|
|
14
|
+
|
|
15
|
+
// POST /players -> typed Promise<Player>, body is a @data class.
|
|
16
|
+
const onCreate = async () => {
|
|
17
|
+
try {
|
|
18
|
+
const names = ['Ada', 'Linus', 'Grace', 'Dennis'];
|
|
19
|
+
const name = names[Math.floor(Math.random() * names.length)];
|
|
20
|
+
const player = await Server.REST.players.create({ body: new NewPlayer(name) });
|
|
21
|
+
note(`created #${player.id} ${player.name}`);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
note(parseError(err));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// POST /players/:id/score -> path param + body; route returns a Response, so we get
|
|
28
|
+
// the raw fetch Response and parse it ourselves.
|
|
29
|
+
const onScore = async () => {
|
|
30
|
+
try {
|
|
31
|
+
const points = BigInt(1 + Math.floor(Math.random() * 10));
|
|
32
|
+
const res = await Server.REST.players.addScore({
|
|
33
|
+
params: { id: 1 },
|
|
34
|
+
body: new ScoreDelta(points)
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
note(`addScore -> ${res.status} (create player #1 first)`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const p = (await res.json()) as Player;
|
|
41
|
+
note(`#${p.id} ${p.name} -> ${p.score}`);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
note(parseError(err));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// GET /leaderboard -> typed Promise<Standings>, a @data wrapper of Player[].
|
|
48
|
+
const onBoard = async () => {
|
|
49
|
+
try {
|
|
50
|
+
const board = await Server.REST.leaderboard.top();
|
|
51
|
+
note('leaderboard: ' + board.players.map((p) => `${p.name}(${p.score})`).join(', '));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
note(parseError(err));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<main>
|
|
59
|
+
<h1>REST</h1>
|
|
60
|
+
<p>
|
|
61
|
+
<code>Server.REST.*</code> is a real, typed <code>fetch</code> client generated from the{' '}
|
|
62
|
+
<code>@rest</code> controllers. It needs the server running to respond.
|
|
63
|
+
</p>
|
|
64
|
+
<button onClick={onCreate}>create player</button> <button onClick={onScore}>award points to #1</button>{' '}
|
|
65
|
+
<button onClick={onBoard}>leaderboard</button>
|
|
66
|
+
<ul>
|
|
67
|
+
{log.map((line, i) => (
|
|
68
|
+
<li key={i}>{line}</li>
|
|
69
|
+
))}
|
|
70
|
+
</ul>
|
|
71
|
+
<Toil.Link href="/">Back home</Toil.Link>
|
|
72
|
+
</main>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Demo of the generated, typed `Server` RPC surface (see ../../shared/server.ts, emitted
|
|
2
|
+
// by the server build from `@service`/`@remote`). `Server` is global (no import) and typed
|
|
3
|
+
// from the server: `Server.ping(n)` (free `@remote`) and `Server.admin.reset()` (a
|
|
4
|
+
// `@service` method). Transport is not wired yet, so a real call throws; this page shows
|
|
5
|
+
// the typing and reports the stub error via the global `parseError`.
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
|
|
8
|
+
export default function RpcDemo() {
|
|
9
|
+
const [result, setResult] = useState('not called');
|
|
10
|
+
|
|
11
|
+
// Scalar in / scalar out: Server.ping is typed (n: number) => Promise<number>.
|
|
12
|
+
const onPing = async () => {
|
|
13
|
+
try {
|
|
14
|
+
const next = await Server.ping(10);
|
|
15
|
+
setResult(`ping -> ${next}`);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
setResult(parseError(err));
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// A @service method: namespaced under its service key.
|
|
22
|
+
const onReset = async () => {
|
|
23
|
+
try {
|
|
24
|
+
await Server.admin.reset();
|
|
25
|
+
setResult('admin.reset -> store cleared');
|
|
26
|
+
} catch (err) {
|
|
27
|
+
setResult(parseError(err));
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<main>
|
|
33
|
+
<h1>RPC</h1>
|
|
34
|
+
<p>
|
|
35
|
+
<code>Server</code> (RPC) is typed from the server build, no import. Calling throws until the transport
|
|
36
|
+
lands. For working server calls today, use the REST client.
|
|
37
|
+
</p>
|
|
38
|
+
<button onClick={onPing}>Server.ping(10)</button> <button onClick={onReset}>Server.admin.reset()</button>
|
|
39
|
+
<p>{result}</p>
|
|
40
|
+
<Toil.Link href="/rest">See the REST demo</Toil.Link> · <Toil.Link href="/">Back home</Toil.Link>
|
|
41
|
+
</main>
|
|
42
|
+
);
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.19",
|
|
5
5
|
"author": "Dacely",
|
|
6
6
|
"description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
|
|
7
7
|
"repository": {
|
|
@@ -50,9 +50,22 @@
|
|
|
50
50
|
"import": "./build/io/index.js",
|
|
51
51
|
"default": "./build/io/index.js"
|
|
52
52
|
},
|
|
53
|
+
"./server/runtime": {
|
|
54
|
+
"types": "./server/runtime/index.ts",
|
|
55
|
+
"default": "./server/runtime/index.ts"
|
|
56
|
+
},
|
|
57
|
+
"./server/runtime/exports": {
|
|
58
|
+
"types": "./server/runtime/exports/index.ts",
|
|
59
|
+
"default": "./server/runtime/exports/index.ts"
|
|
60
|
+
},
|
|
61
|
+
"./server/runtime/*": {
|
|
62
|
+
"types": "./server/runtime/*.ts",
|
|
63
|
+
"default": "./server/runtime/*.ts"
|
|
64
|
+
},
|
|
53
65
|
"./tsconfig": "./presets/tsconfig.json",
|
|
54
66
|
"./eslint": "./presets/eslint.js",
|
|
55
|
-
"./prettier": "./presets/prettier.json"
|
|
67
|
+
"./prettier": "./presets/prettier.json",
|
|
68
|
+
"./prettier-plugin": "./presets/prettier-plugin.js"
|
|
56
69
|
},
|
|
57
70
|
"bin": {
|
|
58
71
|
"toiljs": "./build/cli/index.js"
|
|
@@ -77,7 +90,7 @@
|
|
|
77
90
|
},
|
|
78
91
|
"scripts": {
|
|
79
92
|
"watch": "tsc -p tsconfig.json --watch",
|
|
80
|
-
"build": "npm run build:shared && npm run build:logger && npm run build:client && npm run build:io && npm run build:backend && npm run build:compiler && npm run build:cli
|
|
93
|
+
"build": "npm run build:shared && npm run build:logger && npm run build:client && npm run build:io && npm run build:backend && npm run build:compiler && npm run build:cli",
|
|
81
94
|
"build:shared": "tsc -p tsconfig.shared.json",
|
|
82
95
|
"build:logger": "tsc -p tsconfig.logger.json",
|
|
83
96
|
"build:client": "tsc -p tsconfig.client.json",
|
|
@@ -85,7 +98,6 @@
|
|
|
85
98
|
"build:backend": "tsc -p tsconfig.backend.json",
|
|
86
99
|
"build:compiler": "tsc -p tsconfig.compiler.json",
|
|
87
100
|
"build:cli": "tsc -p tsconfig.cli.json --noEmit && esbuild src/cli/index.ts --bundle --platform=node --format=esm --outfile=build/cli/index.js --external:toiljs/*",
|
|
88
|
-
"build:server": "toilscript --target release",
|
|
89
101
|
"test": "vitest run --coverage",
|
|
90
102
|
"test:watch": "vitest",
|
|
91
103
|
"test:ui": "vitest --ui",
|
|
@@ -94,7 +106,7 @@
|
|
|
94
106
|
"test:server:ci": "asp --config as-pect.config.js --summary --no-logo",
|
|
95
107
|
"test:all": "npm run test && npm run test:server",
|
|
96
108
|
"lint": "eslint src",
|
|
97
|
-
"docs": "typedoc --out docs --tsconfig tsconfig.json --readme README.md --name TOILJS --plugin typedoc-material-theme --themeColor '#cb9820'
|
|
109
|
+
"docs": "typedoc --out docs --tsconfig tsconfig.json --readme README.md --name TOILJS --plugin typedoc-material-theme --themeColor '#cb9820' src",
|
|
98
110
|
"setup": "npm i && npm run build"
|
|
99
111
|
},
|
|
100
112
|
"dependencies": {
|
|
@@ -109,7 +121,7 @@
|
|
|
109
121
|
"eslint-plugin-react-refresh": "^0.5.2",
|
|
110
122
|
"picocolors": "^1.1.1",
|
|
111
123
|
"sharp": "^0.34.5",
|
|
112
|
-
"toilscript": "^0.1.
|
|
124
|
+
"toilscript": "^0.1.11",
|
|
113
125
|
"typescript-eslint": "^8.60.0",
|
|
114
126
|
"vite": "^8.0.14",
|
|
115
127
|
"vite-imagetools": "^10.0.0",
|
|
@@ -124,7 +136,6 @@
|
|
|
124
136
|
},
|
|
125
137
|
"devDependencies": {
|
|
126
138
|
"@babel/core": "^7.29.7",
|
|
127
|
-
"@clack/prompts": "^1.5.0",
|
|
128
139
|
"@babel/preset-env": "^7.29.7",
|
|
129
140
|
"@babel/preset-typescript": "^7.29.7",
|
|
130
141
|
"@btc-vision/as-covers-assembly": "^0.4.5",
|
|
@@ -132,6 +143,7 @@
|
|
|
132
143
|
"@btc-vision/as-pect-assembly": "^8.3.0",
|
|
133
144
|
"@btc-vision/as-pect-cli": "^8.3.0",
|
|
134
145
|
"@btc-vision/as-pect-transform": "^8.3.0",
|
|
146
|
+
"@clack/prompts": "^1.5.0",
|
|
135
147
|
"@microsoft/api-extractor": "7.58.7",
|
|
136
148
|
"@testing-library/dom": "^10.4.1",
|
|
137
149
|
"@testing-library/react": "^16.3.2",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prettier plugin that lets prettier format toilscript server code, which uses native
|
|
3
|
+
* decorators on free functions (`@main`, `@remote function ...`). Those are valid
|
|
4
|
+
* toilscript but not valid ECMAScript/TypeScript grammar, so prettier's parser rejects
|
|
5
|
+
* them outright.
|
|
6
|
+
*
|
|
7
|
+
* The fix is a parse-time/print-time round-trip: in `preprocess` each function decorator
|
|
8
|
+
* is rewritten to a marker block comment (which the TS parser accepts), and the estree
|
|
9
|
+
* printer's `printComment` renders that marker back as the original `@decorator`. Class
|
|
10
|
+
* and method decorators are already valid grammar and pass through untouched.
|
|
11
|
+
*/
|
|
12
|
+
import * as tsPlugin from 'prettier/plugins/typescript';
|
|
13
|
+
import * as estreePlugin from 'prettier/plugins/estree';
|
|
14
|
+
|
|
15
|
+
const baseTs = tsPlugin.parsers.typescript;
|
|
16
|
+
const baseEstree = estreePlugin.printers.estree;
|
|
17
|
+
|
|
18
|
+
const MARKER = '::toil-decorator ';
|
|
19
|
+
// One-or-more bare decorators (`@name`, no args) immediately before a function declaration.
|
|
20
|
+
const FN_DECORATORS =
|
|
21
|
+
/((?:@[A-Za-z_$][\w$]*[ \t]*\r?\n[ \t]*)+)((?:export[ \t]+)?(?:default[ \t]+)?(?:async[ \t]+)?function\b)/g;
|
|
22
|
+
const ONE_DECORATOR = /@([A-Za-z_$][\w$]*)([ \t]*\r?\n[ \t]*)/g;
|
|
23
|
+
|
|
24
|
+
function preprocess(text, options) {
|
|
25
|
+
const pre = baseTs.preprocess ? baseTs.preprocess(text, options) : text;
|
|
26
|
+
return pre.replace(FN_DECORATORS, (_match, decorators, fn) => {
|
|
27
|
+
const masked = decorators.replace(
|
|
28
|
+
ONE_DECORATOR,
|
|
29
|
+
(_d, name, gap) => `/*${MARKER}${name}*/${gap}`,
|
|
30
|
+
);
|
|
31
|
+
return masked + fn;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const parsers = {
|
|
36
|
+
typescript: { ...baseTs, preprocess },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const printers = {
|
|
40
|
+
estree: {
|
|
41
|
+
...baseEstree,
|
|
42
|
+
printComment(path, options) {
|
|
43
|
+
const comment = path.node ?? path.getValue();
|
|
44
|
+
const value = comment?.value;
|
|
45
|
+
if (typeof value === 'string' && value.startsWith(MARKER)) {
|
|
46
|
+
return '@' + value.slice(MARKER.length).trim();
|
|
47
|
+
}
|
|
48
|
+
return baseEstree.printComment(path, options);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
package/presets/prettier.json
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# toiljs server runtime
|
|
2
|
+
|
|
3
|
+
In-tree SDK that bridges a toilscript handler to the toil-backend
|
|
4
|
+
edge's wasm ABI.
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
The edge calls `handle(req_ofs: i32, req_len: i32) -> i64` on every
|
|
9
|
+
request. This runtime gives you:
|
|
10
|
+
|
|
11
|
+
- `Request` / `Response` AssemblyScript types
|
|
12
|
+
- A byte-for-byte envelope codec matching
|
|
13
|
+
`toil-backend/src/http/envelope.rs`
|
|
14
|
+
- A `ToilHandler` base class you extend, plus a `Server` singleton you
|
|
15
|
+
assign `Server.handler = () => new MyHandler()`. The `handle` wasm
|
|
16
|
+
export (re-exported from `toiljs/server/runtime/exports`) decodes the
|
|
17
|
+
request, runs your handler, encodes the response, and returns the
|
|
18
|
+
packed i64 the host expects.
|
|
19
|
+
|
|
20
|
+
## Wire contract
|
|
21
|
+
|
|
22
|
+
Source of truth: `toil-backend/src/http/envelope.rs`.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
request envelope (LE, no padding):
|
|
26
|
+
u8 method 0=GET 1=POST 2=PUT 3=DELETE 4=PATCH 5=HEAD 6=OPTIONS
|
|
27
|
+
u16 path_len
|
|
28
|
+
[u8] path
|
|
29
|
+
u16 n_headers
|
|
30
|
+
for each header: u16 name_len, u16 val_len, [u8] name, [u8] val
|
|
31
|
+
u32 body_len
|
|
32
|
+
[u8] body
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The response envelope is the same shape with the first `u8 method +
|
|
36
|
+
u16 path_len + path` replaced by `u16 status`.
|
|
37
|
+
|
|
38
|
+
The handler must return a packed `i64`:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
(resp_ofs << 32) | resp_len
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The host reads `resp_len` bytes starting at `resp_ofs` in linear
|
|
45
|
+
memory and decodes them as a response envelope.
|
|
46
|
+
|
|
47
|
+
## Memory layout
|
|
48
|
+
|
|
49
|
+
- `[0, req_len)` — request envelope, written by the host before
|
|
50
|
+
`handle` is called.
|
|
51
|
+
- `[65536, 65536 + resp_len)` — response envelope, written by
|
|
52
|
+
`dispatch` (the response base is the second 64 KiB page so the
|
|
53
|
+
request and response never overlap).
|
|
54
|
+
|
|
55
|
+
The edge enforces a 1024-page (64 MiB) linear memory cap via
|
|
56
|
+
`LimitingTunables`, so leaving the first page for the request is
|
|
57
|
+
fine.
|
|
58
|
+
|
|
59
|
+
## Example
|
|
60
|
+
|
|
61
|
+
A user app extends `ToilHandler` and wires it up in `server/main.ts`.
|
|
62
|
+
The runtime is consumed as the `toiljs/server/runtime` library export:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// server/HelloHandler.ts
|
|
66
|
+
import { ToilHandler, Request, Response } from 'toiljs/server/runtime';
|
|
67
|
+
|
|
68
|
+
export class HelloHandler extends ToilHandler {
|
|
69
|
+
public handle(req: Request): Response {
|
|
70
|
+
if (req.path == '/') return Response.text('hello\n');
|
|
71
|
+
if (req.path == '/json') return Response.json('{"ok":true}');
|
|
72
|
+
return Response.notFound();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// server/main.ts
|
|
79
|
+
import { Server } from 'toiljs/server/runtime';
|
|
80
|
+
import { revertOnError } from 'toiljs/server/runtime/abort/abort';
|
|
81
|
+
import { HelloHandler } from './HelloHandler';
|
|
82
|
+
|
|
83
|
+
Server.handler = () => new HelloHandler();
|
|
84
|
+
|
|
85
|
+
// Surface the wasm `handle(i32, i32) -> i64` entry the edge calls.
|
|
86
|
+
export * from 'toiljs/server/runtime/exports';
|
|
87
|
+
|
|
88
|
+
// Forward AS runtime panics to the host's env::abort import.
|
|
89
|
+
export function abort(message: string, fileName: string, line: u32, column: u32): void {
|
|
90
|
+
revertOnError(message, fileName, line, column);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Compile with `toilscript --target release`, drop the resulting
|
|
95
|
+
`build/server/release.wasm` at `<toil-backend>/hosts/<hostname>.wasm`,
|
|
96
|
+
and the edge will route requests with that `Host:` header to it. A
|
|
97
|
+
complete app lives in `examples/basic`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssemblyScript runtime panic hook.
|
|
3
|
+
*
|
|
4
|
+
* Any unhandled `assert()`, out-of-bounds array access, or other
|
|
5
|
+
* runtime failure in the compiled wasm reaches the host's `env::abort`
|
|
6
|
+
* import (see toil-backend's `AbortImport`). For that import to be
|
|
7
|
+
* satisfied at link time the compiled module needs an exported
|
|
8
|
+
* `abort` function with the AS-standard signature; the user's
|
|
9
|
+
* `main.ts` re-exports it as:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* export function abort(message: string, fileName: string, line: u32, column: u32): void {
|
|
13
|
+
* revertOnError(message, fileName, line, column);
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* We just call `unreachable()` after recording the location: wasmer
|
|
18
|
+
* traps the call, the edge's pump catches the trap, marks the
|
|
19
|
+
* instance poisoned, and returns 502 to the client. The location
|
|
20
|
+
* fields are deliberately left untouched in case a future host
|
|
21
|
+
* import wants to read them off the message/fileName strings; today
|
|
22
|
+
* the edge's `AbortImport::execute` only logs `line`/`column`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export function revertOnError(_message: string, _fileName: string, _line: u32, _column: u32): void {
|
|
26
|
+
unreachable();
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server — the runtime singleton, analog to btc-runtime's
|
|
3
|
+
* `Blockchain`.
|
|
4
|
+
*
|
|
5
|
+
* The user's `main.ts` assigns `Server.handler = () => new MyHandler()`.
|
|
6
|
+
* The `handle(req_ofs, req_len)` wasm export in `runtime/exports`
|
|
7
|
+
* calls that factory once per request.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Potential } from '../lang/Potential';
|
|
11
|
+
import { ToilHandler } from '../handlers/ToilHandler';
|
|
12
|
+
|
|
13
|
+
@final
|
|
14
|
+
export class ServerEnvironment {
|
|
15
|
+
/**
|
|
16
|
+
* The user-supplied handler factory. Assigned at module init by
|
|
17
|
+
* the contract's `main.ts`. We use a factory rather than a
|
|
18
|
+
* pre-built instance so the user gets fresh state per request
|
|
19
|
+
* (the alternative would be threading reset logic through every
|
|
20
|
+
* handler class).
|
|
21
|
+
*/
|
|
22
|
+
public handler: () => ToilHandler = defaultHandler;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cached handler instance for the current request. Cleared at the
|
|
26
|
+
* end of every dispatch so the next request runs the factory
|
|
27
|
+
* again. Exposed for tests; user code should not touch it.
|
|
28
|
+
*/
|
|
29
|
+
public _current: Potential<ToilHandler> = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build (or reuse) the handler for this request. Called once per
|
|
33
|
+
* dispatch from `runtime/exports::handle`.
|
|
34
|
+
*/
|
|
35
|
+
public currentHandler(): ToilHandler {
|
|
36
|
+
if (this._current == null) {
|
|
37
|
+
this._current = this.handler();
|
|
38
|
+
}
|
|
39
|
+
return <ToilHandler>this._current;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Drop the cached handler so the next request gets a fresh one.
|
|
44
|
+
* Called at the tail of `runtime/exports::handle`.
|
|
45
|
+
*/
|
|
46
|
+
public resetCurrentHandler(): void {
|
|
47
|
+
this._current = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Default factory used until the user's `main.ts` assigns
|
|
53
|
+
* `Server.handler`. Returns a base handler whose `handle` produces a
|
|
54
|
+
* 404 — useful as a no-op state during early bring-up and as a
|
|
55
|
+
* fallback if the user forgot to wire one up.
|
|
56
|
+
*/
|
|
57
|
+
function defaultHandler(): ToilHandler {
|
|
58
|
+
return new ToilHandler();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const Server: ServerEnvironment = new ServerEnvironment();
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire envelope codec, byte-for-byte compatible with
|
|
3
|
+
* `toil-backend/src/http/envelope.rs`.
|
|
4
|
+
*
|
|
5
|
+
* Layout (LE, no padding):
|
|
6
|
+
*
|
|
7
|
+
* request:
|
|
8
|
+
* u8 method (0=GET, 1=POST, 2=PUT, 3=DELETE,
|
|
9
|
+
* 4=PATCH, 5=HEAD, 6=OPTIONS)
|
|
10
|
+
* u16 path_len
|
|
11
|
+
* [u8] path
|
|
12
|
+
* u16 n_headers
|
|
13
|
+
* for each header: u16 name_len, u16 val_len, [u8] name, [u8] val
|
|
14
|
+
* u32 body_len
|
|
15
|
+
* [u8] body
|
|
16
|
+
*
|
|
17
|
+
* response: same shape but the first u8+u16 (method + path_len)
|
|
18
|
+
* is replaced by `u16 status`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
readBytes,
|
|
23
|
+
readU16,
|
|
24
|
+
readU32,
|
|
25
|
+
readU8,
|
|
26
|
+
readUtf8,
|
|
27
|
+
utf8Length,
|
|
28
|
+
writeBytes,
|
|
29
|
+
writeU16,
|
|
30
|
+
writeU32,
|
|
31
|
+
writeUtf8
|
|
32
|
+
} from './memory';
|
|
33
|
+
import { Header, Method, Request } from './request';
|
|
34
|
+
import { Response } from './response';
|
|
35
|
+
|
|
36
|
+
class DecodeCursor {
|
|
37
|
+
base: usize;
|
|
38
|
+
end: usize;
|
|
39
|
+
cur: usize;
|
|
40
|
+
ok: bool;
|
|
41
|
+
|
|
42
|
+
constructor(base: usize, len: usize) {
|
|
43
|
+
this.base = base;
|
|
44
|
+
this.end = base + len;
|
|
45
|
+
this.cur = base;
|
|
46
|
+
this.ok = true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@inline canTake(n: usize): bool {
|
|
50
|
+
return this.cur + n <= this.end;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
takeU8(): u8 {
|
|
54
|
+
if (!this.canTake(1)) { this.ok = false; return 0; }
|
|
55
|
+
const v = readU8(this.cur);
|
|
56
|
+
this.cur += 1;
|
|
57
|
+
return v;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
takeU16(): u16 {
|
|
61
|
+
if (!this.canTake(2)) { this.ok = false; return 0; }
|
|
62
|
+
const v = readU16(this.cur);
|
|
63
|
+
this.cur += 2;
|
|
64
|
+
return v;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
takeU32(): u32 {
|
|
68
|
+
if (!this.canTake(4)) { this.ok = false; return 0; }
|
|
69
|
+
const v = readU32(this.cur);
|
|
70
|
+
this.cur += 4;
|
|
71
|
+
return v;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
takeBytes(n: u32): Uint8Array {
|
|
75
|
+
if (!this.canTake(<usize>n)) { this.ok = false; return new Uint8Array(0); }
|
|
76
|
+
const out = readBytes(this.cur, n);
|
|
77
|
+
this.cur += <usize>n;
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
takeUtf8(n: u32): string {
|
|
82
|
+
if (!this.canTake(<usize>n)) { this.ok = false; return ''; }
|
|
83
|
+
const s = readUtf8(this.cur, n);
|
|
84
|
+
this.cur += <usize>n;
|
|
85
|
+
return s;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decode the request envelope the host wrote at `req_ofs`. Returns
|
|
91
|
+
* a populated `Request` on success or `null` on truncation /
|
|
92
|
+
* malformed bytes.
|
|
93
|
+
*/
|
|
94
|
+
export function decodeRequest(req_ofs: usize, req_len: usize): Request | null {
|
|
95
|
+
const c = new DecodeCursor(req_ofs, req_len);
|
|
96
|
+
const methodByte = c.takeU8();
|
|
97
|
+
if (!c.ok) return null;
|
|
98
|
+
const method = methodFromByte(methodByte);
|
|
99
|
+
|
|
100
|
+
const pathLen = c.takeU16();
|
|
101
|
+
const path = c.takeUtf8(<u32>pathLen);
|
|
102
|
+
if (!c.ok) return null;
|
|
103
|
+
|
|
104
|
+
const nHeaders = c.takeU16();
|
|
105
|
+
const headers = new Array<Header>();
|
|
106
|
+
for (let i: u32 = 0; i < <u32>nHeaders; i++) {
|
|
107
|
+
const nameLen = c.takeU16();
|
|
108
|
+
const valLen = c.takeU16();
|
|
109
|
+
const name = c.takeUtf8(<u32>nameLen);
|
|
110
|
+
const val = c.takeUtf8(<u32>valLen);
|
|
111
|
+
if (!c.ok) return null;
|
|
112
|
+
headers.push(new Header(name, val));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const bodyLen = c.takeU32();
|
|
116
|
+
const body = c.takeBytes(bodyLen);
|
|
117
|
+
if (!c.ok) return null;
|
|
118
|
+
|
|
119
|
+
return new Request(method, path, headers, body);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@inline function methodFromByte(b: u8): Method {
|
|
123
|
+
if (b == 0) return Method.GET;
|
|
124
|
+
if (b == 1) return Method.POST;
|
|
125
|
+
if (b == 2) return Method.PUT;
|
|
126
|
+
if (b == 3) return Method.DELETE;
|
|
127
|
+
if (b == 4) return Method.PATCH;
|
|
128
|
+
if (b == 5) return Method.HEAD;
|
|
129
|
+
if (b == 6) return Method.OPTIONS;
|
|
130
|
+
return Method.UNKNOWN;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Serialise `resp` into linear memory starting at `dst_ofs`.
|
|
135
|
+
* Returns the total byte length written. Status, header count and
|
|
136
|
+
* body length are bounds-checked: header names and values must each
|
|
137
|
+
* fit in u16, the body must fit in u32, the header count must fit in
|
|
138
|
+
* u16. A handler returning an unrepresentable response gets a
|
|
139
|
+
* minimal 500 envelope written instead so the host never sees an
|
|
140
|
+
* encoder fault.
|
|
141
|
+
*/
|
|
142
|
+
export function encodeResponse(resp: Response, dst_ofs: usize): u32 {
|
|
143
|
+
// Validate fits-in-u16/u32 limits up front so we never half-write.
|
|
144
|
+
if (resp.headers.length > 0xffff) {
|
|
145
|
+
return encodeFallback500(dst_ofs);
|
|
146
|
+
}
|
|
147
|
+
for (let i = 0; i < resp.headers.length; i++) {
|
|
148
|
+
const h = resp.headers[i];
|
|
149
|
+
if (utf8Length(h.name) > 0xffff || utf8Length(h.value) > 0xffff) {
|
|
150
|
+
return encodeFallback500(dst_ofs);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (<u64>resp.body.length > <u64>0xffffffff) {
|
|
154
|
+
return encodeFallback500(dst_ofs);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let cur: usize = dst_ofs;
|
|
158
|
+
|
|
159
|
+
writeU16(cur, resp.status);
|
|
160
|
+
cur += 2;
|
|
161
|
+
|
|
162
|
+
writeU16(cur, <u16>resp.headers.length);
|
|
163
|
+
cur += 2;
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < resp.headers.length; i++) {
|
|
166
|
+
const h = resp.headers[i];
|
|
167
|
+
const nameLen = utf8Length(h.name);
|
|
168
|
+
const valLen = utf8Length(h.value);
|
|
169
|
+
writeU16(cur, <u16>nameLen);
|
|
170
|
+
cur += 2;
|
|
171
|
+
writeU16(cur, <u16>valLen);
|
|
172
|
+
cur += 2;
|
|
173
|
+
cur += writeUtf8(cur, h.name);
|
|
174
|
+
cur += writeUtf8(cur, h.value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const bodyLen = <u32>resp.body.length;
|
|
178
|
+
writeU32(cur, bodyLen);
|
|
179
|
+
cur += 4;
|
|
180
|
+
cur += writeBytes(cur, resp.body);
|
|
181
|
+
|
|
182
|
+
return <u32>(cur - dst_ofs);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function encodeFallback500(dst_ofs: usize): u32 {
|
|
186
|
+
// Minimal valid 500 envelope: status + 0 headers + 0-length body.
|
|
187
|
+
writeU16(dst_ofs, 500);
|
|
188
|
+
writeU16(dst_ofs + 2, 0);
|
|
189
|
+
writeU32(dst_ofs + 4, 0);
|
|
190
|
+
return 8;
|
|
191
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wasm exports the edge calls.
|
|
3
|
+
*
|
|
4
|
+
* The user's `main.ts` does
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* export * from './runtime/exports';
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* to surface `handle(i32, i32) -> i64` (and any future entry points)
|
|
11
|
+
* as wasm exports. The actual work — decode the envelope, run the
|
|
12
|
+
* user's handler via `Server.currentHandler()`, encode the response —
|
|
13
|
+
* lives here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Server } from '../env/Server';
|
|
17
|
+
import { decodeRequest, encodeResponse } from '../envelope';
|
|
18
|
+
import { Response } from '../response';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Linear-memory offset where we lay out the response envelope.
|
|
22
|
+
*
|
|
23
|
+
* The host writes the request envelope starting at offset 0. We pick
|
|
24
|
+
* `65536` (one wasm page in) so the response never overlaps with the
|
|
25
|
+
* request, no matter how big the request grew. The edge's
|
|
26
|
+
* LimitingTunables caps the linear memory at 1024 pages (64 MiB), so
|
|
27
|
+
* we still have 63 MiB of room past `RESPONSE_BASE` for the response
|
|
28
|
+
* envelope.
|
|
29
|
+
*/
|
|
30
|
+
const RESPONSE_BASE: usize = 65536;
|
|
31
|
+
|
|
32
|
+
@main
|
|
33
|
+
export function handle(req_ofs: i32, req_len: i32): i64 {
|
|
34
|
+
let resp: Response;
|
|
35
|
+
|
|
36
|
+
const req = decodeRequest(<usize>req_ofs, <usize>req_len);
|
|
37
|
+
if (req == null) {
|
|
38
|
+
// Truncated or malformed envelope — host shouldn't send these
|
|
39
|
+
// but produce a clean 400 so the dispatcher doesn't see a
|
|
40
|
+
// garbage return value.
|
|
41
|
+
resp = Response.badRequest('malformed request envelope');
|
|
42
|
+
} else {
|
|
43
|
+
const handler = Server.currentHandler();
|
|
44
|
+
handler.onRequestStarted(req);
|
|
45
|
+
resp = handler.handle(req);
|
|
46
|
+
handler.onRequestCompleted(req, resp);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const total = encodeResponse(resp, RESPONSE_BASE);
|
|
50
|
+
Server.resetCurrentHandler();
|
|
51
|
+
return ((<i64>RESPONSE_BASE) << 32) | (<i64>total);
|
|
52
|
+
}
|