toiljs 0.0.16 → 0.0.20
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 +128 -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 -697
- 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 +69 -7
- package/build/compiler/generate.js +5 -4
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +30 -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 +84 -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 +16 -6
- 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 +69 -7
- package/src/compiler/generate.ts +6 -5
- package/src/compiler/index.ts +50 -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,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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class every toiljs server-side handler extends, analog to
|
|
3
|
+
* btc-runtime's `OP_NET`. Override `handle(req)` to produce the
|
|
4
|
+
* response. The framework calls `onRequestStarted` / `onRequestCompleted`
|
|
5
|
+
* around every call so the user can hook for logging or metrics
|
|
6
|
+
* without re-implementing `handle`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Request } from '../request';
|
|
10
|
+
import { Response } from '../response';
|
|
11
|
+
|
|
12
|
+
export class ToilHandler {
|
|
13
|
+
/**
|
|
14
|
+
* Override to declare your routes. The default implementation
|
|
15
|
+
* returns a generic 404 so a handler that hasn't been wired up
|
|
16
|
+
* still produces a valid envelope.
|
|
17
|
+
*/
|
|
18
|
+
public handle(_req: Request): Response {
|
|
19
|
+
return Response.notFound();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Called before each `handle` call. Empty by default; override
|
|
24
|
+
* for per-request setup (logging, header reads, etc.).
|
|
25
|
+
*/
|
|
26
|
+
public onRequestStarted(_req: Request): void {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Called after each `handle` call returns (also when it throws,
|
|
30
|
+
* after the runtime has converted the throw into a 500). Empty by
|
|
31
|
+
* default.
|
|
32
|
+
*/
|
|
33
|
+
public onRequestCompleted(_req: Request, _resp: Response): void {}
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the toiljs server runtime, analog to
|
|
3
|
+
* `@btc-vision/btc-runtime/runtime`. The user does
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { Server, ToilHandler, Response } from './runtime';
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* and then assigns `Server.handler = () => new MyHandler()` in their
|
|
10
|
+
* `main.ts`. The wasm `handle(i32, i32) -> i64` export comes from
|
|
11
|
+
* `./exports`, which the user re-exports with `export * from
|
|
12
|
+
* './runtime/exports'`. The `abort` runtime hook comes from
|
|
13
|
+
* `./abort/abort`, which the user re-exports as a top-level `abort`
|
|
14
|
+
* function in their `main.ts`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { Header, Method, Request } from './request';
|
|
18
|
+
export { Response } from './response';
|
|
19
|
+
export { ToilHandler } from './handlers/ToilHandler';
|
|
20
|
+
export { Server, ServerEnvironment } from './env/Server';
|
|
21
|
+
|
|
22
|
+
// HTTP layer (`@rest` / `@route`).
|
|
23
|
+
export { Rest, RestRegistry, RouteFn } from './rest/Rest';
|
|
24
|
+
export { RouteContext } from './rest/RouteContext';
|
|
25
|
+
export { matchRoute } from './rest/match';
|
|
26
|
+
export { RestHandler } from './rest/RestHandler';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny load/store wrappers around AssemblyScript's linear-memory
|
|
3
|
+
* intrinsics. The envelope codec stays readable when it calls
|
|
4
|
+
* `readU16(p)` instead of `load<u16>(p)` everywhere.
|
|
5
|
+
*
|
|
6
|
+
* All reads are little-endian (matches the wire format produced by
|
|
7
|
+
* `toil-backend/src/http/envelope.rs`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
@inline export function readU8(ofs: usize): u8 {
|
|
11
|
+
return load<u8>(ofs);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@inline export function readU16(ofs: usize): u16 {
|
|
15
|
+
return load<u16>(ofs);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@inline export function readU32(ofs: usize): u32 {
|
|
19
|
+
return load<u32>(ofs);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@inline export function writeU8(ofs: usize, v: u8): void {
|
|
23
|
+
store<u8>(ofs, v);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@inline export function writeU16(ofs: usize, v: u16): void {
|
|
27
|
+
store<u16>(ofs, v);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@inline export function writeU32(ofs: usize, v: u32): void {
|
|
31
|
+
store<u32>(ofs, v);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read `len` bytes starting at `ofs` and return them as a fresh
|
|
36
|
+
* `Uint8Array` (copying out of linear memory). Used for the request
|
|
37
|
+
* body so the handler can hold onto it past the call to `dispatch`.
|
|
38
|
+
*/
|
|
39
|
+
export function readBytes(ofs: usize, len: u32): Uint8Array {
|
|
40
|
+
const out = new Uint8Array(<i32>len);
|
|
41
|
+
memory.copy(changetype<usize>(out.dataStart), ofs, <usize>len);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Write `bytes` into linear memory starting at `ofs`. Returns the
|
|
47
|
+
* number of bytes written.
|
|
48
|
+
*/
|
|
49
|
+
export function writeBytes(ofs: usize, bytes: Uint8Array): u32 {
|
|
50
|
+
const n = <u32>bytes.length;
|
|
51
|
+
memory.copy(ofs, changetype<usize>(bytes.dataStart), <usize>n);
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decode `len` bytes of UTF-8 at `ofs` into a string. The bytes
|
|
57
|
+
* remain in linear memory; the returned string is a fresh AS string.
|
|
58
|
+
*/
|
|
59
|
+
export function readUtf8(ofs: usize, len: u32): string {
|
|
60
|
+
return String.UTF8.decodeUnsafe(ofs, <usize>len);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Encode `s` as UTF-8 into linear memory starting at `ofs`. Returns
|
|
65
|
+
* the number of bytes written.
|
|
66
|
+
*/
|
|
67
|
+
export function writeUtf8(ofs: usize, s: string): u32 {
|
|
68
|
+
const buf = String.UTF8.encode(s);
|
|
69
|
+
const n = <u32>buf.byteLength;
|
|
70
|
+
memory.copy(ofs, changetype<usize>(buf), <usize>n);
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* UTF-8 byte length of `s` without actually writing it anywhere.
|
|
76
|
+
* Used by the response encoder to pre-compute total envelope size
|
|
77
|
+
* before laying out the bytes.
|
|
78
|
+
*/
|
|
79
|
+
@inline export function utf8Length(s: string): u32 {
|
|
80
|
+
return <u32>String.UTF8.byteLength(s, false);
|
|
81
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The incoming HTTP request handed to the user's handler. Decoded
|
|
3
|
+
* from the wire envelope the host wrote at offset 0 of linear
|
|
4
|
+
* memory. See `envelope.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export enum Method {
|
|
8
|
+
GET = 0,
|
|
9
|
+
POST = 1,
|
|
10
|
+
PUT = 2,
|
|
11
|
+
DELETE = 3,
|
|
12
|
+
PATCH = 4,
|
|
13
|
+
HEAD = 5,
|
|
14
|
+
OPTIONS = 6,
|
|
15
|
+
UNKNOWN = 255,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class Header {
|
|
19
|
+
name: string;
|
|
20
|
+
value: string;
|
|
21
|
+
|
|
22
|
+
constructor(name: string, value: string) {
|
|
23
|
+
this.name = name;
|
|
24
|
+
this.value = value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Request {
|
|
29
|
+
method: Method;
|
|
30
|
+
path: string;
|
|
31
|
+
headers: Array<Header>;
|
|
32
|
+
body: Uint8Array;
|
|
33
|
+
|
|
34
|
+
constructor(method: Method, path: string, headers: Array<Header>, body: Uint8Array) {
|
|
35
|
+
this.method = method;
|
|
36
|
+
this.path = path;
|
|
37
|
+
this.headers = headers;
|
|
38
|
+
this.body = body;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Case-insensitive header lookup. Returns `null` if not present.
|
|
43
|
+
* O(n) over the header list; the request typically carries fewer
|
|
44
|
+
* than a dozen, so the linear scan is the right call.
|
|
45
|
+
*/
|
|
46
|
+
header(name: string): string | null {
|
|
47
|
+
const lower = name.toLowerCase();
|
|
48
|
+
for (let i = 0; i < this.headers.length; i++) {
|
|
49
|
+
if (this.headers[i].name.toLowerCase() == lower) {
|
|
50
|
+
return this.headers[i].value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The response the user's handler builds. Serialised into a wire
|
|
3
|
+
* envelope at a fixed offset before `handle()` returns. See
|
|
4
|
+
* `envelope.ts` and `dispatch.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Header } from './request';
|
|
8
|
+
|
|
9
|
+
export class Response {
|
|
10
|
+
status: u16;
|
|
11
|
+
headers: Array<Header>;
|
|
12
|
+
body: Uint8Array;
|
|
13
|
+
|
|
14
|
+
constructor(status: u16, body: Uint8Array, headers: Array<Header> | null = null) {
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.body = body;
|
|
17
|
+
this.headers = headers != null ? headers : new Array<Header>();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public static text(body: string, status: u16 = 200): Response {
|
|
21
|
+
const buf = String.UTF8.encode(body);
|
|
22
|
+
const bytes = Uint8Array.wrap(buf);
|
|
23
|
+
const r = new Response(status, bytes);
|
|
24
|
+
|
|
25
|
+
r.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
26
|
+
|
|
27
|
+
return r;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public static html(body: string, status: u16 = 200): Response {
|
|
31
|
+
const buf = String.UTF8.encode(body);
|
|
32
|
+
const bytes = Uint8Array.wrap(buf);
|
|
33
|
+
const r = new Response(status, bytes);
|
|
34
|
+
|
|
35
|
+
r.setHeader('content-type', 'text/html; charset=utf-8');
|
|
36
|
+
|
|
37
|
+
return r;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public static json(body: string, status: u16 = 200): Response {
|
|
41
|
+
const buf = String.UTF8.encode(body);
|
|
42
|
+
const bytes = Uint8Array.wrap(buf);
|
|
43
|
+
const r = new Response(status, bytes);
|
|
44
|
+
|
|
45
|
+
r.setHeader('content-type', 'application/json; charset=utf-8');
|
|
46
|
+
|
|
47
|
+
return r;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A raw binary body, tagged `application/octet-stream`. Used by `@route`
|
|
52
|
+
* methods with `stream: DataStream.Binary` to ship a `@data` `encode()`.
|
|
53
|
+
*/
|
|
54
|
+
public static bytes(body: Uint8Array, status: u16 = 200): Response {
|
|
55
|
+
const r = new Response(status, body);
|
|
56
|
+
|
|
57
|
+
r.setHeader('content-type', 'application/octet-stream');
|
|
58
|
+
|
|
59
|
+
return r;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public static notFound(): Response {
|
|
63
|
+
return Response.text('not found\n', 404);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public static badRequest(msg: string = 'bad request'): Response {
|
|
67
|
+
return Response.text(msg + '\n', 400);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public static internalError(msg: string = 'internal error'): Response {
|
|
71
|
+
return Response.text(msg + '\n', 500);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public static empty(status: u16): Response {
|
|
75
|
+
return new Response(status, new Uint8Array(0));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Builder-style: returns `this` so calls can chain.
|
|
80
|
+
*/
|
|
81
|
+
public setHeader(name: string, value: string): Response {
|
|
82
|
+
this.headers.push(new Header(name, value));
|
|
83
|
+
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auto-populated REST router. Every `@rest` controller self-registers a
|
|
3
|
+
* dispatcher here at module init (compiler-injected); your handler calls
|
|
4
|
+
* `Rest.dispatch(req)` to try them all. The first controller that matches the
|
|
5
|
+
* method + path wins; `null` means no route matched (fall through to your own
|
|
6
|
+
* logic / static files / 404).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Request } from '../request';
|
|
10
|
+
import { Response } from '../response';
|
|
11
|
+
|
|
12
|
+
/** A controller dispatcher: returns a Response on a route hit, null on a miss. */
|
|
13
|
+
export type RouteFn = (req: Request) => Response | null;
|
|
14
|
+
|
|
15
|
+
export class RestRegistry {
|
|
16
|
+
private fns: Array<RouteFn> = new Array<RouteFn>();
|
|
17
|
+
|
|
18
|
+
/** Compiler-injected: registers a controller's dispatcher. Not for direct use. */
|
|
19
|
+
register(fn: RouteFn): void {
|
|
20
|
+
this.fns.push(fn);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Try every registered controller in registration order; first match wins. */
|
|
24
|
+
dispatch(req: Request): Response | null {
|
|
25
|
+
for (let i = 0; i < this.fns.length; i++) {
|
|
26
|
+
const hit = this.fns[i](req);
|
|
27
|
+
if (hit != null) return hit;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Number of registered controllers (diagnostics / tests). */
|
|
33
|
+
get size(): i32 {
|
|
34
|
+
return this.fns.length;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The process-wide REST router singleton. */
|
|
39
|
+
export const Rest: RestRegistry = new RestRegistry();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A drop-in handler for REST-only projects: dispatches to every `@rest`
|
|
3
|
+
* controller and 404s on a miss. Wire it with
|
|
4
|
+
* `Server.handler = () => new RestHandler()`. If you need custom logic, skip
|
|
5
|
+
* this and call `Rest.dispatch(req)` from your own `ToilHandler` instead.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Request } from '../request';
|
|
9
|
+
import { Response } from '../response';
|
|
10
|
+
import { ToilHandler } from '../handlers/ToilHandler';
|
|
11
|
+
import { Rest } from './Rest';
|
|
12
|
+
|
|
13
|
+
export class RestHandler extends ToilHandler {
|
|
14
|
+
/** Dispatches to the registered `@rest` controllers; returns 404 when none match. */
|
|
15
|
+
handle(req: Request): Response {
|
|
16
|
+
const hit = Rest.dispatch(req);
|
|
17
|
+
if (hit != null) return hit;
|
|
18
|
+
return Response.notFound();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request context handed to a `@route` method. Carries the captured path
|
|
3
|
+
* params (`/todos/:id`), the parsed query string, and the raw `Request`. The
|
|
4
|
+
* compiler builds one of these for you and injects it into any route method
|
|
5
|
+
* that declares a `RouteContext` parameter.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Request } from '../request';
|
|
9
|
+
|
|
10
|
+
export class RouteContext {
|
|
11
|
+
/** The raw incoming request (method, path, headers, body). */
|
|
12
|
+
request: Request;
|
|
13
|
+
|
|
14
|
+
// Parallel arrays rather than a Map: a route has a handful of params, the
|
|
15
|
+
// linear scan is cheaper than hashing, and it keeps the codec-free runtime small.
|
|
16
|
+
private paramKeys: Array<string>;
|
|
17
|
+
private paramVals: Array<string>;
|
|
18
|
+
private queryKeys: Array<string> | null = null;
|
|
19
|
+
private queryVals: Array<string> | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(request: Request, paramKeys: Array<string>, paramVals: Array<string>) {
|
|
22
|
+
this.request = request;
|
|
23
|
+
this.paramKeys = paramKeys;
|
|
24
|
+
this.paramVals = paramVals;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A captured path parameter (`/todos/:id` gives `param("id")`), or "" if absent. */
|
|
28
|
+
param(name: string): string {
|
|
29
|
+
for (let i = 0; i < this.paramKeys.length; i++) {
|
|
30
|
+
if (this.paramKeys[i] == name) return this.paramVals[i];
|
|
31
|
+
}
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A query-string value (`?q=hi` gives `query("q")`), or "" if absent. Not URL-decoded in v1. */
|
|
36
|
+
query(name: string): string {
|
|
37
|
+
this.ensureQuery();
|
|
38
|
+
const keys = this.queryKeys!;
|
|
39
|
+
const vals = this.queryVals!;
|
|
40
|
+
for (let i = 0; i < keys.length; i++) {
|
|
41
|
+
if (keys[i] == name) return vals[i];
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Case-insensitive request header, or null. Delegates to `Request.header`. */
|
|
47
|
+
header(name: string): string | null {
|
|
48
|
+
return this.request.header(name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The raw request body decoded as UTF-8 text (used by the JSON stream codec). */
|
|
52
|
+
text(): string {
|
|
53
|
+
const body = this.request.body;
|
|
54
|
+
if (body.length == 0) return '';
|
|
55
|
+
return String.UTF8.decodeUnsafe(body.dataStart, body.byteLength);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private ensureQuery(): void {
|
|
59
|
+
if (this.queryKeys != null) return;
|
|
60
|
+
const keys = new Array<string>();
|
|
61
|
+
const vals = new Array<string>();
|
|
62
|
+
const path = this.request.path;
|
|
63
|
+
const q = path.indexOf('?');
|
|
64
|
+
if (q >= 0 && q + 1 < path.length) {
|
|
65
|
+
const pairs = path.substring(q + 1).split('&');
|
|
66
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
67
|
+
const pair = pairs[i];
|
|
68
|
+
if (pair.length == 0) continue;
|
|
69
|
+
const eq = pair.indexOf('=');
|
|
70
|
+
if (eq < 0) {
|
|
71
|
+
keys.push(pair);
|
|
72
|
+
vals.push('');
|
|
73
|
+
} else {
|
|
74
|
+
keys.push(pair.substring(0, eq));
|
|
75
|
+
vals.push(pair.substring(eq + 1));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
this.queryKeys = keys;
|
|
80
|
+
this.queryVals = vals;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time route patterns (`/api/todos/:id`) matched against a request path
|
|
3
|
+
* at runtime, capturing `:params`. The compiler emits one `matchRoute(...)` call
|
|
4
|
+
* per route inside a controller's injected `__tryRoute`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Request } from '../request';
|
|
8
|
+
import { RouteContext } from './RouteContext';
|
|
9
|
+
|
|
10
|
+
const COLON: i32 = 0x3a; // ':'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Match `pattern` against `req.path`. Static segments must be equal; a `:name`
|
|
14
|
+
* segment captures the corresponding path segment. The query string is ignored
|
|
15
|
+
* for matching. Returns a populated `RouteContext` on a match, `null` on a miss.
|
|
16
|
+
*/
|
|
17
|
+
export function matchRoute(pattern: string, req: Request): RouteContext | null {
|
|
18
|
+
let path = req.path;
|
|
19
|
+
const q = path.indexOf('?');
|
|
20
|
+
if (q >= 0) path = path.substring(0, q);
|
|
21
|
+
|
|
22
|
+
const pat = splitSegments(pattern);
|
|
23
|
+
const act = splitSegments(path);
|
|
24
|
+
if (pat.length != act.length) return null;
|
|
25
|
+
|
|
26
|
+
const keys = new Array<string>();
|
|
27
|
+
const vals = new Array<string>();
|
|
28
|
+
for (let i = 0; i < pat.length; i++) {
|
|
29
|
+
const seg = pat[i];
|
|
30
|
+
if (seg.length > 0 && seg.charCodeAt(0) == COLON) {
|
|
31
|
+
keys.push(seg.substring(1));
|
|
32
|
+
vals.push(act[i]);
|
|
33
|
+
} else if (seg != act[i]) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return new RouteContext(req, keys, vals);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Split a path on `/`, dropping empty segments (so leading/trailing slashes don't matter). */
|
|
41
|
+
function splitSegments(path: string): Array<string> {
|
|
42
|
+
const out = new Array<string>();
|
|
43
|
+
const parts = path.split('/');
|
|
44
|
+
for (let i = 0; i < parts.length; i++) {
|
|
45
|
+
if (parts[i].length > 0) out.push(parts[i]);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
package/src/backend/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* a WebSocket channel for realtime / live updates.
|
|
5
5
|
*
|
|
6
6
|
* This is the Node "server" that hosts the app on a local machine; it is distinct from the
|
|
7
|
-
* toilscript WASM
|
|
7
|
+
* toilscript WASM runtime in `server/runtime` (the `toiljs/server/runtime` library export).
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
@@ -35,8 +35,18 @@ export interface BackendOptions {
|
|
|
35
35
|
readonly root: string;
|
|
36
36
|
/** Listening port. Default `3000`. */
|
|
37
37
|
readonly port?: number;
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Bind host. Default `127.0.0.1` (loopback only). Pass `0.0.0.0` (or a specific interface) to
|
|
40
|
+
* expose the server on the network; do so deliberately, since the WebSocket channel relays
|
|
41
|
+
* messages between all connected clients.
|
|
42
|
+
*/
|
|
39
43
|
readonly host?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Extra origins allowed to open the WebSocket channel, in addition to the server's own origin.
|
|
46
|
+
* Cross-origin WebSocket handshakes from other origins are rejected (prevents cross-site
|
|
47
|
+
* WebSocket hijacking). Example: `['https://app.example.com']`.
|
|
48
|
+
*/
|
|
49
|
+
readonly allowedOrigins?: readonly string[];
|
|
40
50
|
/** WebSocket channel path. Default `/_toil`. */
|
|
41
51
|
readonly wsPath?: string;
|
|
42
52
|
/** Send permissive CORS headers + handle preflight. Default `true`. */
|
|
@@ -58,6 +68,26 @@ export interface RunningBackend {
|
|
|
58
68
|
close(): Promise<void>;
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Whether a WebSocket upgrade from `origin` may connect. A missing `Origin` (non-browser clients
|
|
73
|
+
* like curl or server-to-server) is allowed; a browser `Origin` must match the host the server was
|
|
74
|
+
* reached at (same-origin) or be in the explicit allowlist. This blocks cross-site WebSocket
|
|
75
|
+
* hijacking, where a page the victim visits opens a socket to this server from their browser.
|
|
76
|
+
*/
|
|
77
|
+
function isWsOriginAllowed(
|
|
78
|
+
origin: string | undefined,
|
|
79
|
+
hostHeader: string | undefined,
|
|
80
|
+
allowed: readonly string[] | undefined,
|
|
81
|
+
): boolean {
|
|
82
|
+
if (!origin) return true;
|
|
83
|
+
if (allowed?.includes(origin)) return true;
|
|
84
|
+
try {
|
|
85
|
+
return new URL(origin).host === hostHeader;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
61
91
|
/** Resolves a request path to a file inside `root`, guarding against path traversal. */
|
|
62
92
|
function resolveStaticFile(root: string, requestPath: string): string | null {
|
|
63
93
|
const decoded = decodeURIComponent(requestPath);
|
|
@@ -74,7 +104,7 @@ function resolveStaticFile(root: string, requestPath: string): string | null {
|
|
|
74
104
|
*/
|
|
75
105
|
export async function startBackend(options: BackendOptions): Promise<RunningBackend> {
|
|
76
106
|
const port = options.port ?? 3000;
|
|
77
|
-
const host = options.host ?? '
|
|
107
|
+
const host = options.host ?? '127.0.0.1';
|
|
78
108
|
const wsPath = options.wsPath ?? '/_toil';
|
|
79
109
|
const cors = options.cors ?? true;
|
|
80
110
|
const root = path.resolve(options.root);
|
|
@@ -137,6 +167,18 @@ export async function startBackend(options: BackendOptions): Promise<RunningBack
|
|
|
137
167
|
},
|
|
138
168
|
);
|
|
139
169
|
|
|
170
|
+
// Gate the WebSocket upgrade on the request Origin, so a cross-origin page in a victim's browser
|
|
171
|
+
// cannot hijack the channel (CSWSH). Registered AFTER `app.ws` so it overrides that route's
|
|
172
|
+
// default upgrade handler (hyper-express links it to the companion ws route). Same-origin and
|
|
173
|
+
// non-browser clients pass; others get 403.
|
|
174
|
+
app.upgrade(wsPath, (request: Request, response: Response) => {
|
|
175
|
+
if (!isWsOriginAllowed(request.headers.origin, request.headers.host, options.allowedOrigins)) {
|
|
176
|
+
response.status(403).send();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
response.upgrade({});
|
|
180
|
+
});
|
|
181
|
+
|
|
140
182
|
app.get('/*', (request: Request, response: Response) => {
|
|
141
183
|
if (response.completed) return;
|
|
142
184
|
const file = resolveStaticFile(root, request.path);
|
package/src/cli/create.ts
CHANGED
|
@@ -114,7 +114,7 @@ function scaffold(
|
|
|
114
114
|
'@types/react-dom': '^19.2.3',
|
|
115
115
|
eslint: '^10.2.0',
|
|
116
116
|
prettier: '^3.8.1',
|
|
117
|
-
toilscript: '^0.1.
|
|
117
|
+
toilscript: '^0.1.13',
|
|
118
118
|
typescript: '^6.0.3',
|
|
119
119
|
};
|
|
120
120
|
for (const dep of requiredPackages(features).sort()) {
|
|
@@ -126,9 +126,8 @@ function scaffold(
|
|
|
126
126
|
type: 'module',
|
|
127
127
|
scripts: {
|
|
128
128
|
dev: 'toiljs dev',
|
|
129
|
-
build: 'toiljs build
|
|
130
|
-
'build:
|
|
131
|
-
'build:server': 'toilscript --target release',
|
|
129
|
+
build: 'toiljs build',
|
|
130
|
+
'build:server': 'toilscript --target release --rpcModule shared/server.ts',
|
|
132
131
|
lint: 'eslint client',
|
|
133
132
|
typecheck: 'tsc --noEmit',
|
|
134
133
|
format: 'prettier --write "client/**/*.{ts,tsx,css,scss,less}" "client/public/**/*.html"',
|
|
@@ -152,10 +151,21 @@ function scaffold(
|
|
|
152
151
|
' },\n' +
|
|
153
152
|
'});\n',
|
|
154
153
|
'tsconfig.json':
|
|
155
|
-
'{\n
|
|
154
|
+
'{\n' +
|
|
155
|
+
' "extends": "toiljs/tsconfig",\n' +
|
|
156
|
+
' "compilerOptions": {\n' +
|
|
157
|
+
' "paths": { "shared/*": ["./shared/*"] }\n' +
|
|
158
|
+
' },\n' +
|
|
159
|
+
' "include": ["client", "shared", "toil-env.d.ts", "toil-routes.d.ts"]\n' +
|
|
160
|
+
'}\n',
|
|
156
161
|
'eslint.config.js': "import toiljs from 'toiljs/eslint';\n\nexport default toiljs;\n",
|
|
157
162
|
'.prettierrc': '"toiljs/prettier"\n',
|
|
158
|
-
'.
|
|
163
|
+
// Generated files don't need formatting. (toilscript server decorators like @main /
|
|
164
|
+
// @remote-on-functions are handled by the toiljs/prettier-plugin, so server/ is not ignored.)
|
|
165
|
+
'.prettierignore':
|
|
166
|
+
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
|
|
167
|
+
'.gitignore':
|
|
168
|
+
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
|
|
159
169
|
// Use the project's pinned TypeScript (node_modules) instead of VS Code's bundled version.
|
|
160
170
|
'.vscode/settings.json':
|
|
161
171
|
JSON.stringify({ 'typescript.tsdk': 'node_modules/typescript/lib' }, null, 4) + '\n',
|