toiljs 0.0.21 → 0.0.23

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.
@@ -0,0 +1,137 @@
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
+ }
@@ -0,0 +1,19 @@
1
+ import { Server } from 'toiljs/server/runtime';
2
+ import { revertOnError } from 'toiljs/server/runtime/abort/abort';
3
+ import { HelloHandler } from './HelloHandler';
4
+
5
+ // DO NOT TOUCH THIS.
6
+ Server.handler = () => {
7
+ // ONLY CHANGE THE HANDLER CLASS NAME.
8
+ // DO NOT ADD CUSTOM LOGIC HERE.
9
+
10
+ return new HelloHandler();
11
+ };
12
+
13
+ // VERY IMPORTANT
14
+ export * from 'toiljs/server/runtime/exports';
15
+
16
+ // VERY IMPORTANT
17
+ export function abort(message: string, fileName: string, line: u32, column: u32): void {
18
+ revertOnError(message, fileName, line, column);
19
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.21",
4
+ "version": "0.0.23",
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": {
@@ -121,7 +121,7 @@
121
121
  "eslint-plugin-react-refresh": "^0.5.2",
122
122
  "picocolors": "^1.1.1",
123
123
  "sharp": "^0.34.5",
124
- "toilscript": "^0.1.14",
124
+ "toilscript": "^0.1.15",
125
125
  "typescript-eslint": "^8.60.0",
126
126
  "vite": "^8.0.14",
127
127
  "vite-imagetools": "^10.0.0",
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.14',
117
+ toilscript: '^0.1.15',
118
118
  typescript: '^6.0.3',
119
119
  };
120
120
  for (const dep of requiredPackages(features).sort()) {
@@ -127,7 +127,7 @@ function scaffold(
127
127
  scripts: {
128
128
  dev: 'toiljs dev',
129
129
  build: 'toiljs build',
130
- 'build:server': 'toilscript --target release --rpcModule shared/server.ts',
130
+ 'build:server': 'toiljs build --server',
131
131
  lint: 'eslint client',
132
132
  typecheck: 'tsc --noEmit',
133
133
  format: 'prettier --write "client/**/*.{ts,tsx,css,scss,less}" "client/public/**/*.html"',
@@ -175,7 +175,12 @@ function scaffold(
175
175
  'toilconfig.json':
176
176
  JSON.stringify(
177
177
  {
178
- entries: ['server/main.ts'],
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'],
179
184
  targets: {
180
185
  release: {
181
186
  outFile: 'build/server/release.wasm',
@@ -216,10 +221,6 @@ function scaffold(
216
221
  null,
217
222
  4,
218
223
  ) + '\n',
219
- 'server/index.ts': 'export function add(a: i32, b: i32): i32 {\n return a + b;\n}\n',
220
- 'server/main.ts':
221
- "import { add } from './index';\n\n" +
222
- '@main\nfunction run(): i32 {\n return add(40, 2);\n}\n',
223
224
  'README.md': [
224
225
  '# ' + path.basename(name),
225
226
  '',
@@ -237,9 +238,12 @@ function scaffold(
237
238
  ].join('\n'),
238
239
  };
239
240
 
240
- // The `app` template's client UI is copied from examples/basic/client at runtime; `minimal` ships an
241
- // inline client here.
242
- if (template === 'minimal') Object.assign(files, minimalClient(name, features));
241
+ // The `app` template's client UI + server are copied from examples/basic at runtime; `minimal`
242
+ // ships an inline client + a minimal working server here.
243
+ if (template === 'minimal') {
244
+ Object.assign(files, minimalClient(name, features));
245
+ Object.assign(files, minimalServer());
246
+ }
243
247
 
244
248
  // Selected AI-assistant pointer files at the root (committed). The real docs are always seeded
245
249
  // under .toil/docs (gitignored; regenerated by dev/build) since the framework manages them.
@@ -251,6 +255,33 @@ function scaffold(
251
255
  return files;
252
256
  }
253
257
 
258
+ /** A minimal but working server for the `minimal` template (the `app` template copies examples/basic/server). */
259
+ function minimalServer(): Record<string, string> {
260
+ return {
261
+ 'server/HelloHandler.ts':
262
+ "import { ToilHandler, Request, Response, Method } from 'toiljs/server/runtime';\n\n" +
263
+ 'export class HelloHandler extends ToilHandler {\n' +
264
+ ' public handle(req: Request): Response {\n' +
265
+ ' if (req.method != Method.GET && req.method != Method.HEAD) {\n' +
266
+ " return Response.empty(405).setHeader('allow', 'GET, HEAD');\n" +
267
+ ' }\n' +
268
+ " return Response.text('hello from toiljs\\n');\n" +
269
+ ' }\n' +
270
+ '}\n',
271
+ 'server/main.ts':
272
+ "import { Server } from 'toiljs/server/runtime';\n" +
273
+ "import { revertOnError } from 'toiljs/server/runtime/abort/abort';\n" +
274
+ "import { HelloHandler } from './HelloHandler';\n\n" +
275
+ '// Wire your handler here.\n' +
276
+ 'Server.handler = () => new HelloHandler();\n\n' +
277
+ '// Required: re-export the WASM entry points and the abort hook.\n' +
278
+ "export * from 'toiljs/server/runtime/exports';\n" +
279
+ 'export function abort(message: string, fileName: string, line: u32, column: u32): void {\n' +
280
+ ' revertOnError(message, fileName, line, column);\n' +
281
+ '}\n',
282
+ };
283
+ }
284
+
254
285
  /** The inline client UI for the `minimal` template (the `app` template copies examples/basic/client). */
255
286
  function minimalClient(name: string, features: StyleFeatures): Record<string, string> {
256
287
  const files: Record<string, string> = {
@@ -351,6 +382,18 @@ function appClientDir(): string {
351
382
  );
352
383
  }
353
384
 
385
+ /** Absolute path to the `app` starter server (`examples/basic/server`), shipped in the package. */
386
+ function appServerDir(): string {
387
+ return path.resolve(
388
+ path.dirname(fileURLToPath(import.meta.url)),
389
+ '..',
390
+ '..',
391
+ 'examples',
392
+ 'basic',
393
+ 'server',
394
+ );
395
+ }
396
+
354
397
  /**
355
398
  * Applies the chosen styling to a copied template's client dir: renames the stylesheet to the
356
399
  * preprocessor's extension, adds the Tailwind entry, and rewrites `toil.tsx`'s style imports.
@@ -526,8 +569,9 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
526
569
  s.start('Scaffolding project');
527
570
  await writeFiles(targetDir, scaffold(name, template, features, aiTools, images));
528
571
  if (template === 'app') {
529
- // Copy the example client (the single starter source), set its <title>, then apply styling.
572
+ // Copy the example client + server (the single starter source), set the <title>, then style.
530
573
  await fs.cp(appClientDir(), path.join(targetDir, 'client'), { recursive: true });
574
+ await fs.cp(appServerDir(), path.join(targetDir, 'server'), { recursive: true });
531
575
  const indexHtml = path.join(targetDir, 'client', 'public', 'index.html');
532
576
  const html = await fs.readFile(indexHtml, 'utf8');
533
577
  await fs.writeFile(
@@ -403,7 +403,7 @@ export function checkWasmBuilt(exists: boolean): Check {
403
403
  // --- Typed RPC (@data / @remote / @service) -------------------------------------------------------
404
404
 
405
405
  /** Minimum toilscript: @rest/@route HTTP layer + RPC codegen + hardened decoders + @data editor decls. */
406
- export const RPC_TOILSCRIPT_MIN = '0.1.14';
406
+ export const RPC_TOILSCRIPT_MIN = '0.1.15';
407
407
 
408
408
  /** Whether each piece of the typed-RPC wiring is in place (computed in `doctor.ts`). */
409
409
  export interface RpcFacts {
package/src/cli/index.ts CHANGED
@@ -30,6 +30,7 @@ interface Flags {
30
30
  json?: boolean;
31
31
  fix?: boolean;
32
32
  target?: string;
33
+ server?: boolean;
33
34
  }
34
35
 
35
36
  function parseArgs(argv: string[]): Flags {
@@ -106,6 +107,9 @@ function parseArgs(argv: string[]): Flags {
106
107
  case '--target':
107
108
  flags.target = argv[++i];
108
109
  break;
110
+ case '--server':
111
+ flags.server = true;
112
+ break;
109
113
  default:
110
114
  if (!arg.startsWith('-') && flags.name === undefined) flags.name = arg;
111
115
  }
@@ -137,6 +141,7 @@ function printHelp(): void {
137
141
  cmd('--no-ai', 'create: skip AI assistant files (CLAUDE.md, etc.)'),
138
142
  cmd('-y, --yes', 'create: accept defaults (non-interactive)'),
139
143
  cmd('--no-install', "create: don't install dependencies"),
144
+ cmd('--server', 'build: build only the server (regenerate shared/server.ts + wasm)'),
140
145
  cmd('--json', 'doctor: machine-readable output'),
141
146
  cmd('--fix', 'doctor: auto-fix what it can (typed-RPC wiring)'),
142
147
  cmd('--target <t>', 'update: latest | minor | patch | newest | greatest'),
@@ -194,9 +199,17 @@ async function main(): Promise<void> {
194
199
 
195
200
  case 'build':
196
201
  banner();
197
- process.stdout.write(dim(' building for production…') + '\n\n');
198
- await build({ root: flags.root });
199
- process.stdout.write('\n' + success(' ✓ ') + bold('build complete') + '\n\n');
202
+ process.stdout.write(
203
+ dim(flags.server ? ' building the server…' : ' building for production…') +
204
+ '\n\n',
205
+ );
206
+ await build({ root: flags.root, serverOnly: flags.server });
207
+ process.stdout.write(
208
+ '\n' +
209
+ success(' ✓ ') +
210
+ bold(flags.server ? 'server build complete' : 'build complete') +
211
+ '\n\n',
212
+ );
200
213
  break;
201
214
 
202
215
  case 'start': {
@@ -215,7 +228,12 @@ async function main(): Promise<void> {
215
228
  case 'doctor':
216
229
  // Skip the banner for --json so stdout stays valid JSON.
217
230
  if (!flags.json) banner();
218
- await runDoctor({ root: flags.root, cwd: process.cwd(), json: flags.json, fix: flags.fix });
231
+ await runDoctor({
232
+ root: flags.root,
233
+ cwd: process.cwd(),
234
+ json: flags.json,
235
+ fix: flags.fix,
236
+ });
219
237
  break;
220
238
 
221
239
  case 'update':
@@ -11,13 +11,81 @@ import { generate } from './generate.js';
11
11
  import { prerenderStaticParams } from './ssg.js';
12
12
  import { createViteConfig } from './vite.js';
13
13
 
14
+ /**
15
+ * Every server `.ts` source file (under the directories of the toilconfig `entries`, or `server/`
16
+ * by default). Passed to toilscript as explicit entries so a dropped-in `@data`/`@rest` file is
17
+ * compiled - and its surface picked up into `shared/server.ts` - even if the toilconfig lists only
18
+ * `main.ts`. Paths are returned relative to `root`.
19
+ */
20
+ /**
21
+ * A `@data`/`@rest`/`@service`/`@remote` declaration - a file with one defines client surface.
22
+ * Anchored to line-start (after indentation) so a mention in a comment (e.g. `// the @rest ...`)
23
+ * does not count.
24
+ */
25
+ const SURFACE_DECORATOR = /^[ \t]*@(data|rest|service|remote)\b/m;
26
+
27
+ function serverEntryFiles(root: string): string[] {
28
+ let entries: string[] = [];
29
+ try {
30
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
31
+ entries?: unknown;
32
+ };
33
+ if (Array.isArray(cfg.entries)) {
34
+ entries = cfg.entries.filter((e): e is string => typeof e === 'string');
35
+ }
36
+ } catch {
37
+ return [];
38
+ }
39
+
40
+ // Start from the toilconfig entries (normalized), then add any server file that declares a
41
+ // surface, so a dropped-in @data/@rest file is compiled even when it is not listed. Non-surface
42
+ // helpers stay out of the entry list - they are still compiled when imported - which also avoids
43
+ // toilscript's "class is not a WebAssembly export" warning for handler classes.
44
+ const result = new Set<string>(entries.map((e) => path.relative(root, path.resolve(root, e))));
45
+
46
+ const dirs = new Set<string>();
47
+ for (const e of entries) dirs.add(path.dirname(path.resolve(root, e)));
48
+ if (dirs.size === 0) dirs.add(path.join(root, 'server'));
49
+
50
+ let scanned = 0;
51
+ const cap = 500;
52
+ const visit = (dir: string, depth: number): void => {
53
+ if (scanned >= cap || depth > 16) return;
54
+ let listing: fs.Dirent[];
55
+ try {
56
+ listing = fs.readdirSync(dir, { withFileTypes: true });
57
+ } catch {
58
+ return;
59
+ }
60
+ for (const entry of listing) {
61
+ if (scanned >= cap) break;
62
+ const full = path.join(dir, entry.name);
63
+ if (entry.isDirectory()) {
64
+ if (entry.name !== 'node_modules') visit(full, depth + 1);
65
+ } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
66
+ scanned++;
67
+ try {
68
+ if (SURFACE_DECORATOR.test(fs.readFileSync(full, 'utf8'))) {
69
+ result.add(path.relative(root, full));
70
+ }
71
+ } catch {
72
+ // unreadable: skip
73
+ }
74
+ }
75
+ }
76
+ };
77
+ for (const dir of dirs) visit(dir, 0);
78
+ return [...result].sort();
79
+ }
80
+
14
81
  /**
15
82
  * Builds the toilscript server target (which also regenerates `shared/server.ts` via
16
83
  * `--rpcModule`) when the project has one, signalled by a `toilconfig.json` at the root. This
17
84
  * runs before the client build/dev so the generated `@data` + `Server` module the client
18
85
  * imports is always current; without it a stale or missing `shared/server.ts` breaks the
19
- * client build. A no-op for client-only projects. Runs the locally installed `toilscript`
20
- * (resolved + invoked via Node, so no `.bin` shim / PATH assumptions).
86
+ * client build. A no-op for client-only projects. Compiles every server `.ts` file (not just the
87
+ * toilconfig entries) so dropped-in `@data`/`@rest` files are picked up. Runs the locally
88
+ * installed `toilscript`, resolved + invoked via Node (no `.bin` shim / PATH assumptions).
21
89
  */
22
90
  async function buildServer(root: string): Promise<void> {
23
91
  if (!fs.existsSync(path.join(root, 'toilconfig.json'))) return;
@@ -39,12 +107,13 @@ async function buildServer(root: string): Promise<void> {
39
107
  );
40
108
  }
41
109
 
110
+ // Explicit entries (every server file) override the toilconfig entries; the target options
111
+ // (optimization, features, runtime) still come from the toilconfig's `release` target.
112
+ const files = serverEntryFiles(root);
113
+ const args = [binJs, ...files, '--target', 'release', '--rpcModule', 'shared/server.ts'];
114
+
42
115
  await new Promise<void>((resolve, reject) => {
43
- const child = spawn(
44
- process.execPath,
45
- [binJs, '--target', 'release', '--rpcModule', 'shared/server.ts'],
46
- { cwd: root, stdio: 'inherit' },
47
- );
116
+ const child = spawn(process.execPath, args, { cwd: root, stdio: 'inherit' });
48
117
  child.on('error', reject);
49
118
  child.on('close', (code) =>
50
119
  code === 0
@@ -59,6 +128,8 @@ export interface ToilCommandOptions {
59
128
  readonly port?: number;
60
129
  /** Bind host for `start`. Defaults to loopback (`127.0.0.1`); pass `0.0.0.0` to expose. */
61
130
  readonly host?: string;
131
+ /** `build` only: build the server (regenerate `shared/server.ts` + the wasm) and skip the client. */
132
+ readonly serverOnly?: boolean;
62
133
  }
63
134
 
64
135
  /** Starts the Vite dev server (HMR + transforms) for the client app. Returns the running server. */
@@ -72,10 +143,12 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
72
143
  return server;
73
144
  }
74
145
 
75
- /** Produces an optimized production SPA bundle in the configured `outDir`. */
146
+ /** Produces an optimized production SPA bundle in the configured `outDir`. With `serverOnly`,
147
+ * builds just the server (regenerates `shared/server.ts` + the wasm) and skips the client. */
76
148
  export async function build(opts: ToilCommandOptions = {}): Promise<void> {
77
149
  const cfg = await loadConfig(opts);
78
150
  await buildServer(cfg.root);
151
+ if (opts.serverOnly) return;
79
152
  generate(cfg);
80
153
  await viteBuild(await createViteConfig(cfg));
81
154
  // SSG: bake per-URL HTML + sitemap for dynamic routes that opt in via `generateStaticParams`.