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.
Files changed (100) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +313 -128
  3. package/as-pect.config.js +1 -1
  4. package/build/backend/.tsbuildinfo +1 -1
  5. package/build/backend/index.d.ts +1 -0
  6. package/build/backend/index.js +20 -1
  7. package/build/cli/.tsbuildinfo +1 -1
  8. package/build/cli/index.js +1320 -696
  9. package/build/client/.tsbuildinfo +1 -1
  10. package/build/client/dev/devtools.js +42 -5
  11. package/build/client/errors.d.ts +1 -0
  12. package/build/client/errors.js +3 -0
  13. package/build/client/index.d.ts +2 -0
  14. package/build/client/index.js +2 -0
  15. package/build/client/rpc.d.ts +1 -0
  16. package/build/client/rpc.js +37 -0
  17. package/build/compiler/.tsbuildinfo +1 -1
  18. package/build/compiler/config.js +3 -1
  19. package/build/compiler/docs.js +62 -5
  20. package/build/compiler/generate.js +5 -4
  21. package/build/compiler/index.d.ts +1 -0
  22. package/build/compiler/index.js +1 -1
  23. package/build/compiler/plugin.js +80 -8
  24. package/build/compiler/seo.js +15 -1
  25. package/build/compiler/ssg.js +7 -1
  26. package/build/compiler/vite.js +25 -0
  27. package/build/io/.tsbuildinfo +1 -1
  28. package/build/io/codec.d.ts +54 -0
  29. package/build/io/codec.js +143 -0
  30. package/build/io/index.d.ts +1 -2
  31. package/build/io/index.js +1 -2
  32. package/eslint.config.js +1 -1
  33. package/examples/basic/client/routes/features/index.tsx +1 -1
  34. package/examples/basic/client/routes/io.tsx +6 -7
  35. package/examples/basic/client/routes/rest.tsx +74 -0
  36. package/examples/basic/client/routes/rpc.tsx +43 -0
  37. package/package.json +19 -7
  38. package/presets/prettier-plugin.js +51 -0
  39. package/presets/prettier.json +1 -0
  40. package/server/runtime/README.md +97 -0
  41. package/server/runtime/abort/abort.ts +27 -0
  42. package/server/runtime/env/Server.ts +61 -0
  43. package/server/runtime/envelope.ts +191 -0
  44. package/server/runtime/exports/index.ts +52 -0
  45. package/server/runtime/handlers/ToilHandler.ts +34 -0
  46. package/server/runtime/index.ts +26 -0
  47. package/server/runtime/lang/Potential.ts +5 -0
  48. package/server/runtime/memory.ts +81 -0
  49. package/server/runtime/request.ts +55 -0
  50. package/server/runtime/response.ts +86 -0
  51. package/server/runtime/rest/Rest.ts +39 -0
  52. package/server/runtime/rest/RestHandler.ts +20 -0
  53. package/server/runtime/rest/RouteContext.ts +82 -0
  54. package/server/runtime/rest/match.ts +48 -0
  55. package/server/runtime/tsconfig.json +7 -0
  56. package/src/backend/index.ts +45 -3
  57. package/src/cli/create.ts +15 -5
  58. package/src/cli/diagnostics.ts +81 -0
  59. package/src/cli/doctor.ts +384 -7
  60. package/src/cli/index.ts +11 -2
  61. package/src/client/dev/devtools.tsx +49 -4
  62. package/src/client/errors.ts +11 -0
  63. package/src/client/index.ts +2 -0
  64. package/src/client/rpc.ts +64 -0
  65. package/src/compiler/config.ts +3 -1
  66. package/src/compiler/docs.ts +62 -5
  67. package/src/compiler/generate.ts +6 -5
  68. package/src/compiler/index.ts +3 -1
  69. package/src/compiler/plugin.ts +99 -11
  70. package/src/compiler/seo.ts +23 -3
  71. package/src/compiler/ssg.ts +10 -1
  72. package/src/compiler/vite.ts +34 -0
  73. package/src/io/FastMap.ts +24 -0
  74. package/src/io/FastSet.ts +15 -1
  75. package/src/io/codec.ts +217 -0
  76. package/src/io/index.ts +1 -2
  77. package/src/io/types.ts +2 -1
  78. package/test/assembly/example.spec.ts +14 -4
  79. package/test/doctor.test.ts +65 -0
  80. package/test/errors.test.ts +21 -0
  81. package/test/io.test.ts +65 -41
  82. package/test/prettier-plugin.test.ts +46 -0
  83. package/test/rpc.test.ts +50 -0
  84. package/tests/data-parity/generated-parity.ts +99 -0
  85. package/tests/data-parity/parity.ts +80 -0
  86. package/tests/data-parity/spec.ts +46 -0
  87. package/tsconfig.json +1 -1
  88. package/tsconfig.server.json +1 -1
  89. package/build/io/BinaryReader.d.ts +0 -44
  90. package/build/io/BinaryReader.js +0 -244
  91. package/build/io/BinaryWriter.d.ts +0 -44
  92. package/build/io/BinaryWriter.js +0 -297
  93. package/build/server/release.wasm +0 -0
  94. package/build/server/release.wat +0 -9
  95. package/src/io/BinaryReader.ts +0 -340
  96. package/src/io/BinaryWriter.ts +0 -385
  97. package/src/server/index.ts +0 -10
  98. package/src/server/main.ts +0 -13
  99. package/src/server/tsconfig.json +0 -4
  100. package/toilconfig.json +0 -30
@@ -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,5 @@
1
+ /**
2
+ * `Potential<T>` is just `T | null`. Mirrors btc-runtime's
3
+ * convention so the runtime's optional fields read consistently.
4
+ */
5
+ export type Potential<T> = T | null;
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "toilscript/std/assembly.json",
3
+ "compilerOptions": {
4
+ "plugins": [{ "name": "toilscript/std/ts-plugin.cjs" }]
5
+ },
6
+ "include": ["./**/*.ts"]
7
+ }
@@ -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 target in `src/server`.
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
- /** Bind host. Default `0.0.0.0`. */
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 ?? '0.0.0.0';
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.2',
117
+ toilscript: '^0.1.11',
118
118
  typescript: '^6.0.3',
119
119
  };
120
120
  for (const dep of requiredPackages(features).sort()) {
@@ -126,9 +126,9 @@ function scaffold(
126
126
  type: 'module',
127
127
  scripts: {
128
128
  dev: 'toiljs dev',
129
- build: 'toiljs build && toilscript --target release',
129
+ build: 'toilscript --target release --rpcModule shared/server.ts && toiljs build',
130
130
  'build:client': 'toiljs build',
131
- 'build:server': 'toilscript --target release',
131
+ 'build:server': 'toilscript --target release --rpcModule shared/server.ts',
132
132
  lint: 'eslint client',
133
133
  typecheck: 'tsc --noEmit',
134
134
  format: 'prettier --write "client/**/*.{ts,tsx,css,scss,less}" "client/public/**/*.html"',
@@ -152,10 +152,20 @@ function scaffold(
152
152
  ' },\n' +
153
153
  '});\n',
154
154
  'tsconfig.json':
155
- '{\n "extends": "toiljs/tsconfig",\n "include": ["client", "toil-env.d.ts", "toil-routes.d.ts"]\n}\n',
155
+ '{\n' +
156
+ ' "extends": "toiljs/tsconfig",\n' +
157
+ ' "compilerOptions": {\n' +
158
+ ' "paths": { "shared/*": ["./shared/*"] }\n' +
159
+ ' },\n' +
160
+ ' "include": ["client", "shared", "toil-env.d.ts", "toil-routes.d.ts"]\n' +
161
+ '}\n',
156
162
  'eslint.config.js': "import toiljs from 'toiljs/eslint';\n\nexport default toiljs;\n",
157
163
  '.prettierrc': '"toiljs/prettier"\n',
158
- '.gitignore': 'node_modules\nbuild\n.toil\ntoil-env.d.ts\ntoil-routes.d.ts\n',
164
+ // Generated files don't need formatting. (toilscript server decorators like @main /
165
+ // @remote-on-functions are handled by the toiljs/prettier-plugin, so server/ is not ignored.)
166
+ '.prettierignore':
167
+ 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
168
+ '.gitignore': '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',