toiljs 0.0.33 → 0.0.34

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.
@@ -106,4 +106,60 @@ export class Response {
106
106
 
107
107
  return this;
108
108
  }
109
+
110
+ /**
111
+ * Mark this response cacheable at the toil edge and/or the browser.
112
+ *
113
+ * `edgeTtlMinutes` caches the response on the edge node (per-core,
114
+ * keyed by host + method + path + request body) for up to that many
115
+ * minutes -- the ONLY way a POST response is cached. `browserTtlSeconds`
116
+ * emits a `Cache-Control: max-age` for the client.
117
+ *
118
+ * Host-side safety, enforced no matter what you ask for: the edge TTL
119
+ * is clamped to 24h, only 2xx responses are edge-cached, a response
120
+ * carrying a `Set-Cookie` is never edge-cached, and an AUTHENTICATED
121
+ * request (one with a `Cookie` or `Authorization` header) is not
122
+ * edge-cached unless you pass `allowAuth = true`. Only mark a response
123
+ * cacheable when its body is a pure function of (host, path, body) --
124
+ * never per-user data keyed by a cookie/header that is not in the path
125
+ * or body, or one user's response could be served to another.
126
+ *
127
+ * Builder-style: returns `this`.
128
+ */
129
+ public cache(
130
+ edgeTtlMinutes: u16,
131
+ browserTtlSeconds: u32 = 0,
132
+ privateScope: bool = false,
133
+ allowAuth: bool = false,
134
+ ): Response {
135
+ let v = '';
136
+ if (edgeTtlMinutes > 0) {
137
+ v = 'edge=' + edgeTtlMinutes.toString();
138
+ }
139
+ if (browserTtlSeconds > 0) {
140
+ if (v.length > 0) v += '; ';
141
+ v += 'browser=' + browserTtlSeconds.toString();
142
+ }
143
+ if (privateScope) {
144
+ if (v.length > 0) v += '; ';
145
+ v += 'scope=private';
146
+ }
147
+ if (allowAuth) {
148
+ if (v.length > 0) v += '; ';
149
+ v += 'auth=1';
150
+ }
151
+ if (v.length > 0) {
152
+ this.setHeader('toil-cache-control', v);
153
+ }
154
+
155
+ return this;
156
+ }
157
+
158
+ /**
159
+ * Shorthand for {@link cache}: edge-cache this response for `minutes`
160
+ * minutes (no browser caching).
161
+ */
162
+ public cacheFor(minutes: u16): Response {
163
+ return this.cache(minutes);
164
+ }
109
165
  }
package/src/cli/create.ts CHANGED
@@ -175,12 +175,10 @@ function scaffold(
175
175
  'toilconfig.json':
176
176
  JSON.stringify(
177
177
  {
178
- // `toiljs build` compiles every server/*.ts so dropped-in @data/@rest files are
179
- // picked up; these entries are the fallback when running `toilscript` directly.
180
- entries:
181
- template === 'minimal'
182
- ? ['server/main.ts']
183
- : ['server/main.ts', 'server/api.ts'],
178
+ // `toiljs build` compiles every decorated server file (recursively) so
179
+ // dropped-in @data/@rest files are picked up; main.ts imports the surface
180
+ // modules so a direct `toilscript` run builds the same server.
181
+ entries: ['server/main.ts'],
184
182
  targets: {
185
183
  release: {
186
184
  outFile: 'build/server/release.wasm',
@@ -261,12 +259,18 @@ function scaffold(
261
259
  return files;
262
260
  }
263
261
 
264
- /** A minimal but working server for the `minimal` template (the `app` template copies examples/basic/server). */
262
+ /**
263
+ * A minimal but working server for the `minimal` template (the `app` template copies
264
+ * examples/basic/server). Same folder conventions as the full starter, just fewer files:
265
+ * the entry in main.ts, the handler under core/, and a README mapping where new
266
+ * routes/services/models go.
267
+ */
265
268
  function minimalServer(): Record<string, string> {
266
269
  return {
267
- 'server/HelloHandler.ts':
270
+ 'server/core/AppHandler.ts':
268
271
  "import { ToilHandler, Request, Response, Method } from 'toiljs/server/runtime';\n\n" +
269
- 'export class HelloHandler extends ToilHandler {\n' +
272
+ '/** Every request enters here. Add `@rest` controllers under routes/ as you grow. */\n' +
273
+ 'export class AppHandler extends ToilHandler {\n' +
270
274
  ' public handle(req: Request): Response {\n' +
271
275
  ' if (req.method != Method.GET && req.method != Method.HEAD) {\n' +
272
276
  " return Response.empty(405).setHeader('allow', 'GET, HEAD');\n" +
@@ -285,15 +289,31 @@ function minimalServer(): Record<string, string> {
285
289
  '}\n',
286
290
  'server/main.ts':
287
291
  "import { Server } from 'toiljs/server/runtime';\n" +
288
- "import { revertOnError } from 'toiljs/server/runtime/abort/abort';\n" +
289
- "import { HelloHandler } from './HelloHandler';\n\n" +
292
+ "import { revertOnError } from 'toiljs/server/runtime/abort/abort';\n\n" +
293
+ "import { AppHandler } from './core/AppHandler';\n\n" +
294
+ '// As you add surface modules (@rest routes, @service/@remote RPC), import them here\n' +
295
+ '// so a direct `toilscript` run builds the same server `toiljs build` does, e.g.:\n' +
296
+ "// import './routes/Players';\n\n" +
290
297
  '// Wire your handler here.\n' +
291
- 'Server.handler = () => new HelloHandler();\n\n' +
298
+ 'Server.handler = () => new AppHandler();\n\n' +
292
299
  '// Required: re-export the WASM entry points and the abort hook.\n' +
293
300
  "export * from 'toiljs/server/runtime/exports';\n" +
294
301
  'export function abort(message: string, fileName: string, line: u32, column: u32): void {\n' +
295
302
  ' revertOnError(message, fileName, line, column);\n' +
296
303
  '}\n',
304
+ 'server/README.md':
305
+ '# server/\n\n' +
306
+ 'Your ToilScript backend, compiled to a single WebAssembly module. One folder per concern:\n\n' +
307
+ '| Folder | What lives here |\n' +
308
+ '| --- | --- |\n' +
309
+ '| `main.ts` | The entry point: wires the handler and imports the surface modules. |\n' +
310
+ '| `core/` | The request handler and shared app logic (state, helpers). |\n' +
311
+ '| `models/` | `@data` classes, the typed wire model shared by HTTP and RPC. One type per file. |\n' +
312
+ '| `routes/` | `@rest` controllers (HTTP). One controller per file, named after its class. |\n' +
313
+ '| `services/` | `@service` classes and free `@remote` functions (typed RPC). |\n' +
314
+ '| `scheduled/` | Reserved for scheduled tasks (not shipped yet). |\n\n' +
315
+ 'New decorated files are picked up automatically by `toiljs build`/`dev`; also add an import\n' +
316
+ 'in `main.ts` so a direct `toilscript` run builds the same server.\n',
297
317
  };
298
318
  }
299
319
 
@@ -586,7 +606,27 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
586
606
  if (template === 'app') {
587
607
  // Copy the example client + server (the single starter source), set the <title>, then style.
588
608
  await fs.cp(appClientDir(), path.join(targetDir, 'client'), { recursive: true });
589
- await fs.cp(appServerDir(), path.join(targetDir, 'server'), { recursive: true });
609
+ // Only the canonical starter layout ships; anything else sitting in the example's
610
+ // server/ (local experiments, scratch entries) stays out of scaffolded apps.
611
+ const serverAllow = new Set([
612
+ 'main.ts',
613
+ 'README.md',
614
+ 'tsconfig.json',
615
+ 'core',
616
+ 'models',
617
+ 'routes',
618
+ 'services',
619
+ 'scheduled',
620
+ ]);
621
+ const serverSrc = appServerDir();
622
+ await fs.cp(serverSrc, path.join(targetDir, 'server'), {
623
+ recursive: true,
624
+ filter: (src) => {
625
+ const rel = path.relative(serverSrc, src);
626
+ if (rel === '') return true;
627
+ return serverAllow.has(rel.split(path.sep)[0]);
628
+ },
629
+ });
590
630
  const indexHtml = path.join(targetDir, 'client', 'public', 'index.html');
591
631
  const html = await fs.readFile(indexHtml, 'utf8');
592
632
  await fs.writeFile(
@@ -628,5 +668,5 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
628
668
  steps.push(`${accent('npm run build')} ${dim('build for production')}`);
629
669
  note(steps.map((l) => dim(' ') + l).join('\n'), 'Next steps');
630
670
 
631
- outro(`Created ${accent(path.basename(name))}, happy building! ${dim('· v' + version())}`);
671
+ outro(`Created ${accent(path.basename(name))}, happy building! ${dim('(v' + version() + ')')}`);
632
672
  }
Binary file
@@ -25,6 +25,7 @@ import path from 'node:path';
25
25
  import { Server, type Request, type Response } from '@dacely/hyper-express';
26
26
  import pc from 'picocolors';
27
27
 
28
+ import { applyCacheRule, lookupCache } from './cache.js';
28
29
  import { METHOD_CODES, type EnvelopeRequest } from './envelope.js';
29
30
  import { WasmServerModule } from './module.js';
30
31
  import { proxyToVite, wireWebsocketProxy, type ViteTarget } from './proxy.js';
@@ -210,10 +211,29 @@ export async function startDevServer(options: DevServerOptions): Promise<Running
210
211
 
211
212
  if (dispatchable && module.available) {
212
213
  const envelopeReq = await toEnvelopeRequest(request);
214
+ // Honor the tenant cache directive locally, same rules as the
215
+ // edge: serve an identical request from the per-process cache,
216
+ // else dispatch and apply/strip the directive on the response.
217
+ const cacheHost = request.headers.host ?? 'dev';
218
+ const hasAuth =
219
+ request.headers.cookie !== undefined || request.headers.authorization !== undefined;
220
+ const cached = lookupCache(cacheHost, request.method, request.url, envelopeReq.body);
221
+ if (cached !== null) {
222
+ sendWasmResponse(response, root, cached);
223
+ return;
224
+ }
213
225
  try {
214
226
  const result = module.dispatch(envelopeReq);
215
227
  if (!result.unhandled) {
216
- sendWasmResponse(response, root, result);
228
+ const finalized = applyCacheRule(
229
+ cacheHost,
230
+ request.method,
231
+ request.url,
232
+ envelopeReq.body,
233
+ hasAuth,
234
+ result,
235
+ );
236
+ sendWasmResponse(response, root, finalized);
217
237
  return;
218
238
  }
219
239
  } catch (e) {
@@ -155,10 +155,10 @@ describe.skipIf(!fs.existsSync(EXAMPLE_WASM))('dispatch into the example server
155
155
  });
156
156
 
157
157
  it('serves a plain route', () => {
158
- const r = get(load(), '/');
158
+ const r = get(load(), '/json');
159
159
  expect(r.status).toBe(200);
160
160
  expect(r.unhandled).toBe(false);
161
- expect(Buffer.from(r.body).toString()).toBe('hello from toiljs\n');
161
+ expect(Buffer.from(r.body).toString()).toBe('{"hello":"toiljs"}\n');
162
162
  });
163
163
 
164
164
  it('serves a @rest route with its content-type', () => {
@@ -186,8 +186,10 @@ describe.skipIf(!fs.existsSync(EXAMPLE_WASM))('dispatch into the example server
186
186
  body: new TextEncoder().encode('{"name":"ada"}'),
187
187
  });
188
188
  expect(r.unhandled).toBe(false);
189
- expect(r.status).toBeGreaterThanOrEqual(200);
190
- expect(r.status).toBeLessThan(500);
189
+ expect(r.status).toBe(200);
190
+ // u256 id and i64 score cross JSON as decimal strings, exact at any size (3 seeded
191
+ // players, so the new player's id is 4).
192
+ expect(Buffer.from(r.body).toString()).toBe('{"id":"4","name":"ada","score":"0"}');
191
193
  });
192
194
 
193
195
  it('keeps requests isolated across instances (fresh state per dispatch)', () => {
@@ -0,0 +1,27 @@
1
+ // Fixture for the generated-client JSON wire-format test (test/rpc-bignum-wire.test.ts).
2
+ // Compiled by the installed toilscript with --rpcModule, then the generated TS client is
3
+ // imported and exercised. Covers every JSON bignum width plus a nested @data so the
4
+ // recursive toJSONValue path is hit.
5
+
6
+ @data
7
+ class Wallet {
8
+ u: u64 = 0;
9
+ i: i64 = 0;
10
+ a: u128 = u128.Zero;
11
+ b: i128 = i128.Zero;
12
+ c: u256 = u256.Zero;
13
+ d: i256 = i256.Zero;
14
+ label: string = '';
15
+ }
16
+
17
+ @data
18
+ class Account {
19
+ main: Wallet = new Wallet();
20
+ ids: u256[] = [];
21
+ }
22
+
23
+ // A free @remote so buildServerModule emits a surface (it returns null otherwise).
24
+ @remote
25
+ function touch(n: i32): i32 {
26
+ return n;
27
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Regression test for the bignum JSON wire format. 64-bit-and-up integers
3
+ * (u64/i64/u128/i128/u256/i256) must cross JSON as DECIMAL STRINGS, not number tokens
4
+ * or limb arrays: JSON numbers ride through a browser client's JSON.parse as IEEE
5
+ * doubles, which silently corrupt any integer past 2^53.
6
+ *
7
+ * Compiles test/fixtures/bignum-wire/spec.ts with the installed toilscript (so it
8
+ * exercises the published compiler + generated client, not a hand-written stub), then
9
+ * imports the generated TS client and asserts the wire shape both directions, including
10
+ * values far above 2^53 and the legacy limb-array shape older servers emitted.
11
+ */
12
+ import { spawnSync } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
17
+
18
+ import { beforeAll, describe, expect, it } from 'vitest';
19
+
20
+ const here = path.dirname(fileURLToPath(import.meta.url));
21
+ const spec = path.join(here, 'fixtures', 'bignum-wire', 'spec.ts');
22
+ // The generated module imports DataWriter/DataReader from this specifier.
23
+ const codec = path.join(here, '..', 'src', 'io', 'codec.ts');
24
+
25
+ /** Resolves the installed toilscript CLI entry (no PATH / .bin assumptions). */
26
+ function toilscriptBin(): string {
27
+ const pkgPath = require.resolve('toilscript/package.json');
28
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin?: Record<string, string> };
29
+ const binRel = pkg.bin?.toilscript;
30
+ if (!binRel) throw new Error('toilscript declares no bin');
31
+ return path.join(path.dirname(pkgPath), binRel);
32
+ }
33
+
34
+ interface Wallet {
35
+ u: bigint;
36
+ i: bigint;
37
+ a: bigint;
38
+ b: bigint;
39
+ c: bigint;
40
+ d: bigint;
41
+ label: string;
42
+ toJSONValue(): Record<string, unknown>;
43
+ }
44
+ interface WalletStatic {
45
+ new (): Wallet;
46
+ fromJSONValue(v: unknown): Wallet;
47
+ }
48
+ interface AccountStatic {
49
+ new (): { main: Wallet; ids: bigint[]; toJSONValue(): Record<string, unknown> };
50
+ fromJSONValue(v: unknown): { main: Wallet; ids: bigint[] };
51
+ }
52
+
53
+ let Wallet: WalletStatic;
54
+ let Account: AccountStatic;
55
+ let tmp: string;
56
+
57
+ beforeAll(async () => {
58
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bignum-wire-'));
59
+ fs.writeFileSync(path.join(tmp, 'package.json'), '{ "type": "module" }\n');
60
+ // vitest transforms the imported .ts through Vite/oxc, which walks up for a tsconfig.
61
+ fs.writeFileSync(
62
+ path.join(tmp, 'tsconfig.json'),
63
+ JSON.stringify({ compilerOptions: { target: 'esnext', module: 'esnext' } }),
64
+ );
65
+ const mod = path.join(tmp, 'server.ts');
66
+ const wasm = path.join(tmp, 'spec.wasm');
67
+ const res = spawnSync(
68
+ process.execPath,
69
+ [
70
+ toilscriptBin(),
71
+ spec,
72
+ '-o',
73
+ wasm,
74
+ '--runtime',
75
+ 'stub',
76
+ '--initialMemory',
77
+ '32',
78
+ '--rpcModule',
79
+ mod,
80
+ '--rpcRuntime',
81
+ codec,
82
+ ],
83
+ { encoding: 'utf8' },
84
+ );
85
+ if (res.status !== 0) throw new Error('toilscript compile failed:\n' + res.stderr);
86
+ const gen = (await import(pathToFileURL(mod).href)) as {
87
+ Wallet: WalletStatic;
88
+ Account: AccountStatic;
89
+ };
90
+ Wallet = gen.Wallet;
91
+ Account = gen.Account;
92
+ });
93
+
94
+ // A few representative bignum types; `huge` is well past Number.MAX_SAFE_INTEGER.
95
+ const huge = '123456789012345678901234567890';
96
+ const u128Max = '340282366920938463463374607431768211455';
97
+ const i64Min = '-9223372036854775808';
98
+
99
+ describe('generated client bignum JSON wire format', () => {
100
+ it('serializes every 64-bit-and-up integer as a decimal string', () => {
101
+ const w = new Wallet();
102
+ w.u = BigInt(huge.slice(0, 19)); // fits u64
103
+ w.i = BigInt(i64Min);
104
+ w.a = BigInt(u128Max);
105
+ w.b = -123n;
106
+ w.c = BigInt(huge);
107
+ w.d = -1n;
108
+ w.label = 'x';
109
+ const json = w.toJSONValue();
110
+ // Each bignum field is a string, never a number or an array.
111
+ for (const k of ['u', 'i', 'a', 'b', 'c', 'd'] as const) {
112
+ expect(typeof json[k], `field ${k}`).toBe('string');
113
+ }
114
+ expect(json.a).toBe(u128Max);
115
+ expect(json.c).toBe(huge);
116
+ expect(json.i).toBe(i64Min);
117
+ expect(json.label).toBe('x');
118
+ });
119
+
120
+ it('revives a decimal string past 2^53 into an exact bigint', () => {
121
+ const w = Wallet.fromJSONValue({
122
+ u: '0',
123
+ i: i64Min,
124
+ a: u128Max,
125
+ b: '-123',
126
+ c: huge,
127
+ d: '-1',
128
+ label: 'y',
129
+ });
130
+ expect(w.c).toBe(BigInt(huge));
131
+ expect(w.a).toBe(BigInt(u128Max));
132
+ expect(w.i).toBe(BigInt(i64Min));
133
+ expect(w.d).toBe(-1n);
134
+ });
135
+
136
+ it('round-trips a value through send then revive without loss', () => {
137
+ const w = new Wallet();
138
+ w.c = BigInt(huge);
139
+ w.d = BigInt('-' + huge);
140
+ const back = Wallet.fromJSONValue(JSON.parse(JSON.stringify(w.toJSONValue())));
141
+ expect(back.c).toBe(BigInt(huge));
142
+ expect(back.d).toBe(BigInt('-' + huge));
143
+ });
144
+
145
+ it('still revives the legacy little-endian limb-array shape (back-compat)', () => {
146
+ // u256 [5,0,4,0] little-endian = 5 + 4*2^128.
147
+ const w = Wallet.fromJSONValue({ c: [5, 0, 4, 0], a: [9, 1] });
148
+ expect(w.c).toBe(2n ** 130n + 5n);
149
+ expect(w.a).toBe(2n ** 64n + 9n);
150
+ });
151
+
152
+ it('recurses into nested @data and arrays of bignums', () => {
153
+ const a = new Account();
154
+ a.main.c = BigInt(huge);
155
+ a.ids = [1n, BigInt(huge)];
156
+ const json = a.toJSONValue();
157
+ const main = json.main as Record<string, unknown>;
158
+ expect(main.c).toBe(huge);
159
+ expect(json.ids).toEqual(['1', huge]);
160
+ const back = Account.fromJSONValue(JSON.parse(JSON.stringify(json)));
161
+ expect(back.main.c).toBe(BigInt(huge));
162
+ expect(back.ids).toEqual([1n, BigInt(huge)]);
163
+ });
164
+ });
@@ -1,137 +0,0 @@
1
- // A small REST demo: a players list + a leaderboard.
2
- //
3
- // IMPORTANT - the server runs with a FRESH WebAssembly instance per request, so linear memory
4
- // (and any module-level state, like the `store` below) is reset on every request. It does NOT
5
- // persist across requests. We seed a few players at module init so the read routes always have
6
- // data; the write routes (create / addScore) take effect only for the current request's response.
7
- // For real persistence, call out to a database or KV store from your handler.
8
- //
9
- // `@data` types are the wire model (shared by HTTP and RPC). `@rest` controllers expose HTTP
10
- // routes; `@service`/`@remote` expose typed RPC. Building the server (toilscript --rpcModule)
11
- // regenerates the typed client into shared/server.ts:
12
- // @rest -> Server.REST.<controller>.<route>(args) (a working fetch client)
13
- // @service -> Server.<service>.<method>() (RPC, transport TODO)
14
-
15
- import { Response, RouteContext } from 'toiljs/server/runtime';
16
-
17
- /** A leaderboard player. */
18
- @data
19
- class Player {
20
- id: u64 = 0;
21
- name: string = '';
22
- score: i64 = 0;
23
- }
24
-
25
- /** Request body for `POST /players` - the fields a client supplies to create a player. */
26
- @data
27
- class NewPlayer {
28
- name: string = '';
29
- }
30
-
31
- /** Request body for `POST /players/:id/score` - points to add to a player's score. */
32
- @data
33
- class ScoreDelta {
34
- points: i64 = 0;
35
- }
36
-
37
- /** A leaderboard page. A `@data` wrapper so the `Player[]` round-trips through the codec. */
38
- @data
39
- class Standings {
40
- players: Player[] = [];
41
- }
42
-
43
- // Re-seeded on EVERY request - module memory does not persist across requests (see the note at
44
- // the top of the file). Swap this for a database/KV to keep data between requests.
45
- const store = new Map<u64, Player>();
46
- let nextId: u64 = 1;
47
-
48
- function seed(name: string, score: i64): void {
49
- const p = new Player();
50
- p.id = nextId++;
51
- p.name = name;
52
- p.score = score;
53
- store.set(p.id, p);
54
- }
55
- seed('Ada', 120);
56
- seed('Linus', 95);
57
- seed('Grace', 140);
58
-
59
- /**
60
- * Players, mounted at `/players`. On the client:
61
- * await Server.REST.players.get({ params: { id } })
62
- * await Server.REST.players.create({ body: new NewPlayer('Bob') })
63
- * await Server.REST.players.addScore({ params: { id }, body: new ScoreDelta(10n) })
64
- */
65
- @rest('players')
66
- class Players {
67
- /** `GET /players/:id` - returns a `Response` for full control: a real 404 for a missing id,
68
- * a custom header, and the `@data` body serialized with `toJSON()`. (The toilscript editor
69
- * plugin types the compiler-injected `toJSON()`, so this is clean; return the `@data` type
70
- * directly, like the other routes, when you do not need that control.) */
71
- @get('/:id')
72
- public get(ctx: RouteContext): Response {
73
- const id = u64.parse(ctx.param('id'));
74
- if (!store.has(id)) return Response.notFound();
75
- const p = store.get(id);
76
-
77
- return Response.json(p.toJSON().toString()).setHeader('cache-control', 'no-store');
78
- }
79
-
80
- /** `POST /players` - build a player from the request body and return it with a fresh id.
81
- * Note: it is NOT saved (memory resets next request); persist to a real store to keep it. */
82
- @post('/')
83
- public create(input: NewPlayer): Player {
84
- const p = new Player();
85
- p.id = nextId++;
86
- p.name = input.name;
87
- p.score = 0;
88
- store.set(p.id, p);
89
-
90
- return p;
91
- }
92
-
93
- /** `POST /players/:id/score` - add `points` (from the body) to the seeded player named by
94
- * `:id` and return it. The change applies to this response only (memory resets next request). */
95
- @post('/:id/score')
96
- public addScore(input: ScoreDelta, ctx: RouteContext): Player {
97
- const id = u64.parse(ctx.param('id'));
98
- if (!store.has(id)) return new Player();
99
- const p = store.get(id);
100
- p.score += input.points;
101
-
102
- return p;
103
- }
104
- }
105
-
106
- /**
107
- * The leaderboard, mounted at `/leaderboard`. On the client:
108
- * const board = await Server.REST.leaderboard.top(); // typed Standings { players: Player[] }
109
- */
110
- @rest('leaderboard')
111
- class Leaderboard {
112
- /** `GET /leaderboard` - the seeded players, highest score first. */
113
- @get('/')
114
- public top(): Standings {
115
- const board = new Standings();
116
- const all = store.values();
117
- for (let i = 0; i < all.length; i++) board.players.push(all[i]);
118
- board.players.sort((a: Player, b: Player): i32 => (a.score < b.score ? 1 : a.score > b.score ? -1 : 0));
119
- return board;
120
- }
121
- }
122
-
123
- /** Typed RPC service (transport still a TODO): reached as `Server.stats.playerCount()` on the client. */
124
- @service
125
- class Stats {
126
- /** Number of seeded players (the RPC transport is a TODO, so this throws on the client for now). */
127
- @remote
128
- public playerCount(): i32 {
129
- return store.size;
130
- }
131
- }
132
-
133
- /** A free `@remote` function: `Server.ping(n)` on the client. */
134
- @remote
135
- function ping(n: i32): i32 {
136
- return n + 1;
137
- }