toiljs 0.0.16 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -0
- package/README.md +313 -128
- package/as-pect.config.js +1 -1
- package/build/backend/.tsbuildinfo +1 -1
- package/build/backend/index.d.ts +1 -0
- package/build/backend/index.js +20 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +1320 -696
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/devtools.js +42 -5
- package/build/client/errors.d.ts +1 -0
- package/build/client/errors.js +3 -0
- package/build/client/index.d.ts +2 -0
- package/build/client/index.js +2 -0
- package/build/client/rpc.d.ts +1 -0
- package/build/client/rpc.js +37 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.js +3 -1
- package/build/compiler/docs.js +62 -5
- package/build/compiler/generate.js +5 -4
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +1 -1
- package/build/compiler/plugin.js +80 -8
- package/build/compiler/seo.js +15 -1
- package/build/compiler/ssg.js +7 -1
- package/build/compiler/vite.js +25 -0
- package/build/io/.tsbuildinfo +1 -1
- package/build/io/codec.d.ts +54 -0
- package/build/io/codec.js +143 -0
- package/build/io/index.d.ts +1 -2
- package/build/io/index.js +1 -2
- package/eslint.config.js +1 -1
- package/examples/basic/client/routes/features/index.tsx +1 -1
- package/examples/basic/client/routes/io.tsx +6 -7
- package/examples/basic/client/routes/rest.tsx +74 -0
- package/examples/basic/client/routes/rpc.tsx +43 -0
- package/package.json +19 -7
- package/presets/prettier-plugin.js +51 -0
- package/presets/prettier.json +1 -0
- package/server/runtime/README.md +97 -0
- package/server/runtime/abort/abort.ts +27 -0
- package/server/runtime/env/Server.ts +61 -0
- package/server/runtime/envelope.ts +191 -0
- package/server/runtime/exports/index.ts +52 -0
- package/server/runtime/handlers/ToilHandler.ts +34 -0
- package/server/runtime/index.ts +26 -0
- package/server/runtime/lang/Potential.ts +5 -0
- package/server/runtime/memory.ts +81 -0
- package/server/runtime/request.ts +55 -0
- package/server/runtime/response.ts +86 -0
- package/server/runtime/rest/Rest.ts +39 -0
- package/server/runtime/rest/RestHandler.ts +20 -0
- package/server/runtime/rest/RouteContext.ts +82 -0
- package/server/runtime/rest/match.ts +48 -0
- package/server/runtime/tsconfig.json +7 -0
- package/src/backend/index.ts +45 -3
- package/src/cli/create.ts +15 -5
- package/src/cli/diagnostics.ts +81 -0
- package/src/cli/doctor.ts +384 -7
- package/src/cli/index.ts +11 -2
- package/src/client/dev/devtools.tsx +49 -4
- package/src/client/errors.ts +11 -0
- package/src/client/index.ts +2 -0
- package/src/client/rpc.ts +64 -0
- package/src/compiler/config.ts +3 -1
- package/src/compiler/docs.ts +62 -5
- package/src/compiler/generate.ts +6 -5
- package/src/compiler/index.ts +3 -1
- package/src/compiler/plugin.ts +99 -11
- package/src/compiler/seo.ts +23 -3
- package/src/compiler/ssg.ts +10 -1
- package/src/compiler/vite.ts +34 -0
- package/src/io/FastMap.ts +24 -0
- package/src/io/FastSet.ts +15 -1
- package/src/io/codec.ts +217 -0
- package/src/io/index.ts +1 -2
- package/src/io/types.ts +2 -1
- package/test/assembly/example.spec.ts +14 -4
- package/test/doctor.test.ts +65 -0
- package/test/errors.test.ts +21 -0
- package/test/io.test.ts +65 -41
- package/test/prettier-plugin.test.ts +46 -0
- package/test/rpc.test.ts +50 -0
- package/tests/data-parity/generated-parity.ts +99 -0
- package/tests/data-parity/parity.ts +80 -0
- package/tests/data-parity/spec.ts +46 -0
- package/tsconfig.json +1 -1
- package/tsconfig.server.json +1 -1
- package/build/io/BinaryReader.d.ts +0 -44
- package/build/io/BinaryReader.js +0 -244
- package/build/io/BinaryWriter.d.ts +0 -44
- package/build/io/BinaryWriter.js +0 -297
- package/build/server/release.wasm +0 -0
- package/build/server/release.wat +0 -9
- package/src/io/BinaryReader.ts +0 -340
- package/src/io/BinaryWriter.ts +0 -385
- package/src/server/index.ts +0 -10
- package/src/server/main.ts +0 -13
- package/src/server/tsconfig.json +0 -4
- package/toilconfig.json +0 -30
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts a human-readable message from an unknown thrown value. Handy in `catch`
|
|
3
|
+
* blocks where the caught value is typed `unknown`. Exposed as a global `parseError`
|
|
4
|
+
* (no import) alongside the other toiljs globals.
|
|
5
|
+
*
|
|
6
|
+
* @param err - the caught value (an `Error`, a string, or anything else).
|
|
7
|
+
* @returns the `Error.message` when `err` is an `Error`, otherwise `String(err)`.
|
|
8
|
+
*/
|
|
9
|
+
export function parseError(err: unknown): string {
|
|
10
|
+
return err instanceof Error ? err.message : String(err);
|
|
11
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -87,3 +87,5 @@ export { Form } from './components/Form.js';
|
|
|
87
87
|
export type { FormProps } from './components/Form.js';
|
|
88
88
|
export { Slot } from './components/Slot.js';
|
|
89
89
|
export type { SlotProps } from './components/Slot.js';
|
|
90
|
+
export { Server } from './rpc.js';
|
|
91
|
+
export { parseError } from './errors.js';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-callable server surface (`Server`). Its TYPED shape is generated by
|
|
3
|
+
* the toilscript server build into the project's `shared/server.ts`
|
|
4
|
+
* (`declare global { const Server }`); this module is the runtime behind it.
|
|
5
|
+
*
|
|
6
|
+
* Two surfaces live under `Server`:
|
|
7
|
+
* - `Server.REST.<controller>.<route>(args)` is a WORKING fetch client. The
|
|
8
|
+
* generated `shared/server.ts` attaches it to `globalThis.__toilRest` when
|
|
9
|
+
* imported; the proxy below surfaces it under `Server.REST`.
|
|
10
|
+
* - `Server.<service>.<method>()` (RPC) is not wired yet, so any call throws.
|
|
11
|
+
* The pipeline (tags -> generated types -> proxy) is in place; only the
|
|
12
|
+
* network dispatch is a TODO. Build the server (`npm run build:server`) to
|
|
13
|
+
* (re)generate the typed surface.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A recursive proxy that throws on call, used when the REST client hasn't loaded. */
|
|
17
|
+
function restMissingStub(path: string): unknown {
|
|
18
|
+
const call = (): never => {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`toiljs REST: ${path}() is unavailable. The generated REST client has not loaded - ` +
|
|
21
|
+
`import a type from your 'shared/server' (so the client attaches), or run 'npm run build:server'.`,
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
return new Proxy(call, {
|
|
25
|
+
get(_target, prop) {
|
|
26
|
+
if (typeof prop === 'symbol' || prop === 'then') return undefined;
|
|
27
|
+
return restMissingStub(`${path}.${String(prop)}`);
|
|
28
|
+
},
|
|
29
|
+
apply() {
|
|
30
|
+
return call();
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Builds a recursive proxy that throws on call, supporting `Server.svc.method()`. */
|
|
36
|
+
function rpcStub(path: string): unknown {
|
|
37
|
+
const call = (): never => {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`toiljs RPC: ${path}() is not available yet. The client<->server transport ` +
|
|
40
|
+
`is not wired; this is a generated stub. Remote calls will work once transport lands.`,
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
return new Proxy(call, {
|
|
44
|
+
get(_target, prop) {
|
|
45
|
+
// Not thenable, and ignore symbol probes (e.g. from awaiting/inspection).
|
|
46
|
+
if (typeof prop === 'symbol' || prop === 'then') return undefined;
|
|
47
|
+
// `Server.REST` surfaces the generated fetch client attached by shared/server.ts.
|
|
48
|
+
if (path === 'Server' && prop === 'REST') {
|
|
49
|
+
const rest = (globalThis as { __toilRest?: unknown }).__toilRest;
|
|
50
|
+
return rest !== undefined ? rest : restMissingStub('Server.REST');
|
|
51
|
+
}
|
|
52
|
+
return rpcStub(`${path}.${prop}`);
|
|
53
|
+
},
|
|
54
|
+
apply() {
|
|
55
|
+
return call();
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Runtime value for the global `Server`. Typed as `unknown` here; the real types
|
|
62
|
+
* come from the generated `shared/server.ts`. toiljs assigns this to `globalThis`.
|
|
63
|
+
*/
|
|
64
|
+
export const Server: unknown = rpcStub('Server');
|
package/src/compiler/config.ts
CHANGED
|
@@ -211,7 +211,9 @@ export async function loadConfig(
|
|
|
211
211
|
transitions: client.transitions ?? false,
|
|
212
212
|
devtools: client.devtools !== false,
|
|
213
213
|
devtoolsAi:
|
|
214
|
-
|
|
214
|
+
client.devtools != null && typeof client.devtools === 'object'
|
|
215
|
+
? (client.devtools.ai ?? null)
|
|
216
|
+
: null,
|
|
215
217
|
seo: client.seo ?? null,
|
|
216
218
|
runtimePath: resolveRuntimePath(),
|
|
217
219
|
vite: client.vite ?? {},
|
package/src/compiler/docs.ts
CHANGED
|
@@ -85,14 +85,17 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
85
85
|
'- `client/`, the app: `routes/` (file-based), `layout.tsx`, `components/`, `styles/`,',
|
|
86
86
|
' `public/`, and `toil.tsx` (the entry that calls `Toil.mount`).',
|
|
87
87
|
'- `server/`, the toilscript → WASM target (`@main` entry), compiled by `toilscript`.',
|
|
88
|
+
' `@data`/`@remote`/`@service` here generate the typed client `Server` API (see `server.md`).',
|
|
88
89
|
'- `toil.config.ts`, configuration via `defineConfig` (`toiljs.config.ts` also works).',
|
|
89
90
|
'- Generated, gitignored, do not edit: `.toil/` (working dir), `toil-env.d.ts` (ambient',
|
|
90
|
-
' globals), `toil-routes.d.ts` (typed routes).',
|
|
91
|
+
' globals), `toil-routes.d.ts` (typed routes), `shared/server.ts` (the typed RPC module,',
|
|
92
|
+
' emitted by the server build; import `@data` classes from `shared/server`).',
|
|
91
93
|
'',
|
|
92
94
|
'## Key ideas',
|
|
93
95
|
'',
|
|
94
96
|
'- `Toil` is a native global (no import): `Toil.Link`, `Toil.useRouter`, `Toil.useLoaderData`,',
|
|
95
|
-
' etc. The IO classes (`
|
|
97
|
+
' etc. The IO classes (`FastMap`, `FastSet`, `DataWriter`, `DataReader`), `parseError`, and the',
|
|
98
|
+
' generated `Server` RPC surface are globals too.',
|
|
96
99
|
'- Scripts: `npm run dev` (HMR), `npm run build` (→ `build/client` + `build/server`),',
|
|
97
100
|
' `npm start` (self-host the build).',
|
|
98
101
|
'',
|
|
@@ -161,7 +164,14 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
161
164
|
'- Data: `useLoaderData` (see `routing.md`)',
|
|
162
165
|
'- Head: `useHead`, `useTitle`, `<Head>`, set the `<title>` / meta per route',
|
|
163
166
|
'- Realtime: `useChannel`, `connectChannel` (WebSocket to the backend at `/_toil`)',
|
|
164
|
-
'- IO globals (no `Toil.` prefix): `
|
|
167
|
+
'- IO globals (no `Toil.` prefix): `FastMap`, `FastSet`, `DataWriter`, `DataReader`',
|
|
168
|
+
'- `parseError(err)` global: message from an unknown caught value (handy in `catch`)',
|
|
169
|
+
'- `Server` global: the typed RPC surface generated from the server (see `server.md`)',
|
|
170
|
+
'- `Server.REST.<controller>.<route>(args)`: a working, typed `fetch` client for your',
|
|
171
|
+
' `@rest` controllers, e.g. `await Server.REST.todos.getTodo({ params: { id } })` or',
|
|
172
|
+
' `await Server.REST.todos.add({ body: new AddTodo("milk") })`. `args` is',
|
|
173
|
+
' `{ params?, body?, query?, headers? }`; returns are typed (`@data` classes are parsed for',
|
|
174
|
+
' you). The REST client attaches when you import from `shared/server`.',
|
|
165
175
|
'',
|
|
166
176
|
'## Head example',
|
|
167
177
|
'',
|
|
@@ -202,9 +212,53 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
202
212
|
'- `server/index.ts`, your functions.',
|
|
203
213
|
'- `server/tsconfig.json`, extends `toilscript/std/assembly.json` (AssemblyScript/toilscript',
|
|
204
214
|
' globals like `i32`, not the DOM), so editors resolve server types correctly.',
|
|
205
|
-
'- `npm run build:server` (or `npm run build`) emits `build/server/release.wasm
|
|
215
|
+
'- `npm run build:server` (or `npm run build`) emits `build/server/release.wasm` and',
|
|
216
|
+
' regenerates `shared/server.ts` (the typed client RPC module).',
|
|
206
217
|
'',
|
|
207
|
-
'
|
|
218
|
+
'## Typed RPC (`@data` / `@remote` / `@service`)',
|
|
219
|
+
'',
|
|
220
|
+
'Tag server code and the build generates a typed client `Server` surface:',
|
|
221
|
+
'',
|
|
222
|
+
'- `@data class X {}`, a serializable struct. Generates a client class with the same fields',
|
|
223
|
+
' plus `encode`/`decode`; construct it on the client: `import { X } from "shared/server"`.',
|
|
224
|
+
'- `@remote function f(a: T): R`, a client-callable endpoint, becomes `Server.f(a)`.',
|
|
225
|
+
'- `@service class S { @remote m(...) {} }`, namespaces methods: `Server.s.m(...)`.',
|
|
226
|
+
'',
|
|
227
|
+
'On the client, `Server` is a global (no import) and fully typed; every call is async',
|
|
228
|
+
'(`Promise<R>`). Inputs/outputs are scalars, arrays, or `@data` classes, both directions.',
|
|
229
|
+
'',
|
|
230
|
+
'Note: the client↔server transport is not wired yet, so calling a `Server` method throws',
|
|
231
|
+
'until it lands; the typed surface + codec are generated and ready.',
|
|
232
|
+
'',
|
|
233
|
+
'## HTTP REST (`@rest` / `@route`)',
|
|
234
|
+
'',
|
|
235
|
+
'Tag a class `@rest` and its methods with a verb to expose a real HTTP API. Unlike RPC,',
|
|
236
|
+
'the generated client is working `fetch` code (it is just HTTP).',
|
|
237
|
+
'',
|
|
238
|
+
'- `@rest("api") class Todos {}`, mounts the controller at `/api` (bare `@rest` → `/`).',
|
|
239
|
+
'- `@get("/todos/:id")` / `@post` / `@put` / `@del` / `@patch` / `@head` / `@options`, verb',
|
|
240
|
+
' shortcuts; or `@route({ method: Methods.GET, path: "/todos", stream: DataStream.JSON })`.',
|
|
241
|
+
'- A method takes an optional `@data` body + an optional `ctx: RouteContext` (path params via',
|
|
242
|
+
' `ctx.param("id")`, `ctx.query(...)`, `ctx.header(...)`), and returns a `@data` type (encoded',
|
|
243
|
+
' per `stream`) or a `Response` (sent verbatim). `stream` is `DataStream.JSON` (default) or',
|
|
244
|
+
' `DataStream.Binary` (lossless for large `u64`/bignum).',
|
|
245
|
+
'',
|
|
246
|
+
'Each `@rest` class self-registers; dispatch them from your handler - it composes, it never',
|
|
247
|
+
'takes over `handle()`:',
|
|
248
|
+
'',
|
|
249
|
+
'```ts',
|
|
250
|
+
'import { ToilHandler, Request, Response, Rest } from "toiljs/server/runtime";',
|
|
251
|
+
'export class App extends ToilHandler {',
|
|
252
|
+
' public handle(req: Request): Response {',
|
|
253
|
+
' const hit = Rest.dispatch(req); // try every @rest controller',
|
|
254
|
+
' if (hit != null) return hit;',
|
|
255
|
+
' return Response.notFound(); // your own logic / static fallback',
|
|
256
|
+
' }',
|
|
257
|
+
'}',
|
|
258
|
+
'```',
|
|
259
|
+
'',
|
|
260
|
+
'For a REST-only project, `Server.handler = () => new RestHandler()` does the same with no',
|
|
261
|
+
'boilerplate. On the client: `Server.REST.todos.getTodo({ params: { id } })` (see client.md).',
|
|
208
262
|
]),
|
|
209
263
|
'cli.md': doc([
|
|
210
264
|
'# CLI',
|
|
@@ -215,6 +269,9 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
215
269
|
'- `toiljs build`, production build → `build/client` (chain `toilscript` for the server).',
|
|
216
270
|
'- `toiljs start`, self-host the built app (hyper-express) with a `/_toil` WebSocket channel.',
|
|
217
271
|
'- `toiljs configure`, toggle styling features on an existing project (see `styling.md`).',
|
|
272
|
+
'- `toiljs doctor`, diagnose project setup (`--json` for CI). `--fix` auto-wires the typed-RPC',
|
|
273
|
+
' setup (build scripts, tsconfig `shared` + alias, `.gitignore`, toilscript version, and the',
|
|
274
|
+
' toilscript prettier plugin) so an existing project upgrades in one command.',
|
|
218
275
|
]),
|
|
219
276
|
};
|
|
220
277
|
|
package/src/compiler/generate.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { scanRoutes, type ScannedRoute } from './routes.js';
|
|
|
8
8
|
import { llmsTxt, robotsTxt, sitemapXml } from './seo.js';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Contents of the root `toil-env.d.ts`: ambient global types so `new
|
|
11
|
+
* Contents of the root `toil-env.d.ts`: ambient global types so `new DataWriter()` etc. resolve
|
|
12
12
|
* in the IDE without an import. Script-mode declaration (no top-level import/export → the
|
|
13
13
|
* `declare const`s are truly global, and it's not a module that could confuse ESLint's project
|
|
14
14
|
* service); the inline `import('toiljs/io')` type only needs the normal `toiljs/io` export.
|
|
@@ -55,10 +55,11 @@ export const TOIL_ENV_DTS =
|
|
|
55
55
|
` type PageMeta = import('toiljs/client').PageMeta;\n` +
|
|
56
56
|
` type SearchHints = import('toiljs/client').SearchHints;\n` +
|
|
57
57
|
`}\n` +
|
|
58
|
-
`declare const BinaryWriter: typeof import('toiljs/io').BinaryWriter;\n` +
|
|
59
|
-
`declare const BinaryReader: typeof import('toiljs/io').BinaryReader;\n` +
|
|
60
58
|
`declare const FastMap: typeof import('toiljs/io').FastMap;\n` +
|
|
61
59
|
`declare const FastSet: typeof import('toiljs/io').FastSet;\n` +
|
|
60
|
+
`declare const DataWriter: typeof import('toiljs/io').DataWriter;\n` +
|
|
61
|
+
`declare const DataReader: typeof import('toiljs/io').DataReader;\n` +
|
|
62
|
+
`declare const parseError: typeof import('toiljs/client').parseError;\n` +
|
|
62
63
|
`\n` +
|
|
63
64
|
`${STYLE_MODULES}\n` +
|
|
64
65
|
`\n` +
|
|
@@ -290,9 +291,9 @@ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
|
|
|
290
291
|
`// @ts-nocheck\n` +
|
|
291
292
|
`// AUTO-GENERATED by toil, do not edit.\n` +
|
|
292
293
|
`import * as Toil from 'toiljs/client';\n` +
|
|
293
|
-
`import {
|
|
294
|
+
`import { FastMap, FastSet, DataWriter, DataReader } from 'toiljs/io';\n` +
|
|
294
295
|
`import { pages } from './routes';\n\n` +
|
|
295
|
-
`Object.assign(globalThis, { Toil,
|
|
296
|
+
`Object.assign(globalThis, { Toil, FastMap, FastSet, DataWriter, DataReader, Server: Toil.Server, parseError: Toil.parseError });\n` +
|
|
296
297
|
`Toil.setViewTransitions(${String(cfg.viewTransitions)});\n` +
|
|
297
298
|
`Toil.setTransitions(${String(cfg.transitions)});\n` +
|
|
298
299
|
`Toil.registerPages(pages);\n`;
|
package/src/compiler/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { createViteConfig } from './vite.js';
|
|
|
12
12
|
export interface ToilCommandOptions {
|
|
13
13
|
readonly root?: string;
|
|
14
14
|
readonly port?: number;
|
|
15
|
+
/** Bind host for `start`. Defaults to loopback (`127.0.0.1`); pass `0.0.0.0` to expose. */
|
|
16
|
+
readonly host?: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/** Starts the Vite dev server (HMR + transforms) for the client app. Returns the running server. */
|
|
@@ -44,7 +46,7 @@ export async function start(opts: ToilCommandOptions = {}): Promise<RunningBacke
|
|
|
44
46
|
if (!fs.existsSync(path.join(outDir, 'index.html'))) {
|
|
45
47
|
throw new Error(`No build found in ${outDir}. Run \`toiljs build\` first.`);
|
|
46
48
|
}
|
|
47
|
-
return startBackend({ root: outDir, port: cfg.port });
|
|
49
|
+
return startBackend({ root: outDir, port: cfg.port, host: opts.host });
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export { defineConfig, loadConfig, AiProvider } from './config.js';
|
package/src/compiler/plugin.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import { type IncomingMessage } from 'node:http';
|
|
3
4
|
import { createRequire } from 'node:module';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -102,6 +103,37 @@ function devInfo(cfg: ResolvedToilConfig, port: number): Record<string, unknown>
|
|
|
102
103
|
};
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Resolves a request's `file` param to an absolute path that is genuinely inside the project root,
|
|
108
|
+
* or null. Guards against `..` traversal, sibling-prefix escapes (`<root>-evil/secret` passes a bare
|
|
109
|
+
* `startsWith(root)`), and symlinks inside the project that point outside it (realpath re-check).
|
|
110
|
+
*/
|
|
111
|
+
function safeProjectPath(cfg: ResolvedToilConfig, file: string | null): string | null {
|
|
112
|
+
if (!file) return null;
|
|
113
|
+
const root = cfg.root;
|
|
114
|
+
const inside = (p: string): boolean => p === root || p.startsWith(root + path.sep);
|
|
115
|
+
const abs = path.resolve(file);
|
|
116
|
+
if (!inside(abs) || !fs.existsSync(abs)) return null;
|
|
117
|
+
try {
|
|
118
|
+
const real = fs.realpathSync(abs);
|
|
119
|
+
return inside(real) ? real : null;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* True for requests that must NOT reach the dev endpoints (they open files, read source, and spend
|
|
127
|
+
* AI credits). Uses an allowlist on the browser-set `Sec-Fetch-Site`: only `same-origin` (the
|
|
128
|
+
* toolbar), `none` (user-initiated, e.g. the address bar), or an absent header (non-browser tooling
|
|
129
|
+
* like curl) are allowed. Everything else, `cross-site`, `same-site`, or any unexpected value, is
|
|
130
|
+
* rejected. This blocks CSRF (a malicious site's fetch/img) without breaking local dev tooling.
|
|
131
|
+
*/
|
|
132
|
+
function isCrossSiteRequest(headers: IncomingMessage['headers']): boolean {
|
|
133
|
+
const site = headers['sec-fetch-site'];
|
|
134
|
+
return site !== undefined && site !== 'same-origin' && site !== 'none';
|
|
135
|
+
}
|
|
136
|
+
|
|
105
137
|
/** Opens `file` in the user's editor (best-effort): `$EDITOR file`, else `code -g file`. */
|
|
106
138
|
function openInEditor(file: string): void {
|
|
107
139
|
try {
|
|
@@ -150,7 +182,12 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
|
150
182
|
configureServer(server) {
|
|
151
183
|
// Dev toolbar endpoints (dev only). `/__toil/devinfo` -> build/config snapshot;
|
|
152
184
|
// `/__toil/open?file=` -> open the file in the editor.
|
|
153
|
-
server.middlewares.use('/__toil/devinfo', (
|
|
185
|
+
server.middlewares.use('/__toil/devinfo', (req, res) => {
|
|
186
|
+
if (isCrossSiteRequest(req.headers)) {
|
|
187
|
+
res.statusCode = 403;
|
|
188
|
+
res.end();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
154
191
|
const port = server.config.server.port ?? cfg.port;
|
|
155
192
|
res.setHeader('content-type', 'application/json');
|
|
156
193
|
res.end(JSON.stringify(devInfo(cfg, port)));
|
|
@@ -158,6 +195,11 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
|
158
195
|
// `/__toil/ai` -> server-side AI proxy. The key is read from the env here and never
|
|
159
196
|
// reaches the browser; 404 when AI isn't configured (the toolbar then only hands off).
|
|
160
197
|
server.middlewares.use('/__toil/ai', (req, res) => {
|
|
198
|
+
if (isCrossSiteRequest(req.headers)) {
|
|
199
|
+
res.statusCode = 403;
|
|
200
|
+
res.end();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
161
203
|
if (req.method !== 'POST') {
|
|
162
204
|
res.statusCode = 405;
|
|
163
205
|
res.end();
|
|
@@ -170,31 +212,52 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
|
170
212
|
return;
|
|
171
213
|
}
|
|
172
214
|
let body = '';
|
|
173
|
-
|
|
215
|
+
let aborted = false;
|
|
216
|
+
req.on('data', (chunk) => {
|
|
217
|
+
if (aborted) return;
|
|
218
|
+
body += String(chunk);
|
|
219
|
+
if (body.length > 100_000) {
|
|
220
|
+
// Cap the request body so a runaway/malicious POST can't grow it unbounded.
|
|
221
|
+
aborted = true;
|
|
222
|
+
res.statusCode = 413;
|
|
223
|
+
res.end();
|
|
224
|
+
req.destroy();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
174
227
|
req.on('end', () => {
|
|
228
|
+
if (aborted) return;
|
|
175
229
|
void (async () => {
|
|
176
230
|
try {
|
|
177
|
-
const
|
|
178
|
-
|
|
231
|
+
const parsed = JSON.parse(body || '{}') as { prompt?: string };
|
|
232
|
+
// Cap the prompt actually forwarded upstream (independent of the raw-body cap).
|
|
233
|
+
const prompt =
|
|
234
|
+
typeof parsed.prompt === 'string' ? parsed.prompt.slice(0, 16000) : '';
|
|
235
|
+
const text = await aiComplete(ai, prompt);
|
|
179
236
|
res.setHeader('content-type', 'application/json');
|
|
180
237
|
res.end(JSON.stringify({ text }));
|
|
181
238
|
} catch (e) {
|
|
239
|
+
// Log the detail to the dev's terminal; return a generic message to the
|
|
240
|
+
// client so upstream/provider error text is never reflected over HTTP.
|
|
241
|
+
process.stderr.write(
|
|
242
|
+
`toil: /__toil/ai failed: ${e instanceof Error ? e.message : String(e)}\n`,
|
|
243
|
+
);
|
|
182
244
|
res.statusCode = 500;
|
|
183
245
|
res.setHeader('content-type', 'application/json');
|
|
184
|
-
res.end(
|
|
185
|
-
JSON.stringify({ error: e instanceof Error ? e.message : String(e) }),
|
|
186
|
-
);
|
|
246
|
+
res.end(JSON.stringify({ error: 'AI request failed (see dev server logs).' }));
|
|
187
247
|
}
|
|
188
248
|
})();
|
|
189
249
|
});
|
|
190
250
|
});
|
|
191
251
|
server.middlewares.use('/__toil/open', (req, res) => {
|
|
192
252
|
try {
|
|
253
|
+
if (isCrossSiteRequest(req.headers)) {
|
|
254
|
+
res.statusCode = 403;
|
|
255
|
+
res.end();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
193
258
|
const url = new URL(req.url ?? '', 'http://localhost');
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
// Only files inside the project root, never an arbitrary path.
|
|
197
|
-
if (abs && abs.startsWith(cfg.root) && fs.existsSync(abs)) openInEditor(abs);
|
|
259
|
+
const abs = safeProjectPath(cfg, url.searchParams.get('file'));
|
|
260
|
+
if (abs) openInEditor(abs);
|
|
198
261
|
res.statusCode = 204;
|
|
199
262
|
res.end();
|
|
200
263
|
} catch {
|
|
@@ -202,6 +265,31 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
|
202
265
|
res.end();
|
|
203
266
|
}
|
|
204
267
|
});
|
|
268
|
+
// `/__toil/source?file=` -> the file's text, so the AI tab can include the page's code in
|
|
269
|
+
// its prompt. Same root-confinement (`safeProjectPath`) as `/__toil/open`; capped so a
|
|
270
|
+
// stray huge file can't bloat the response.
|
|
271
|
+
server.middlewares.use('/__toil/source', (req, res) => {
|
|
272
|
+
try {
|
|
273
|
+
if (isCrossSiteRequest(req.headers)) {
|
|
274
|
+
res.statusCode = 403;
|
|
275
|
+
res.end();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const url = new URL(req.url ?? '', 'http://localhost');
|
|
279
|
+
const abs = safeProjectPath(cfg, url.searchParams.get('file'));
|
|
280
|
+
if (!abs) {
|
|
281
|
+
res.statusCode = 404;
|
|
282
|
+
res.end();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const text = fs.readFileSync(abs, 'utf8').slice(0, 20000);
|
|
286
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
287
|
+
res.end(text);
|
|
288
|
+
} catch {
|
|
289
|
+
res.statusCode = 400;
|
|
290
|
+
res.end();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
205
293
|
|
|
206
294
|
// Trailing slash so a sibling like `routes-extra/` doesn't match the `routes/` prefix.
|
|
207
295
|
const routesPrefix = cfg.routesAbsDir.replace(/\\/g, '/').replace(/\/?$/, '/');
|
package/src/compiler/seo.ts
CHANGED
|
@@ -135,6 +135,22 @@ function escapeAttr(value: string): string {
|
|
|
135
135
|
export function escapeHtml(value: string): string {
|
|
136
136
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Neutralizes a string for safe single-line Markdown interpolation (e.g. `llms.txt`): collapses
|
|
140
|
+
* newlines/control chars to a space and backslash-escapes the characters that would break out of a
|
|
141
|
+
* `[text](url)` link or start a new block (`[ ] ( ) < > \``). Without this, a page title/description
|
|
142
|
+
* from `generateMetadata` could inject extra list items, links, or headings.
|
|
143
|
+
*/
|
|
144
|
+
function escapeMarkdownInline(value: string): string {
|
|
145
|
+
return value
|
|
146
|
+
.replace(/[\r\n\t\f\v]+/g, ' ')
|
|
147
|
+
.replace(/[\\[\]()<>`]/g, (c) => `\\${c}`)
|
|
148
|
+
.trim();
|
|
149
|
+
}
|
|
150
|
+
/** Escapes a URL for a Markdown link target: strips whitespace/control chars and parens. */
|
|
151
|
+
function escapeMarkdownUrl(value: string): string {
|
|
152
|
+
return value.replace(/\s+/g, '').replace(/[()<>]/g, encodeURIComponent);
|
|
153
|
+
}
|
|
138
154
|
/**
|
|
139
155
|
* Serializes a value for embedding in an inline `<script>` (JSON-LD). Escapes `<`, `>`, and `&`,
|
|
140
156
|
* which neutralizes `</script>` and `<!--` (the only HTML-significant sequences inside a script),
|
|
@@ -388,9 +404,13 @@ export function llmsTxt(
|
|
|
388
404
|
if (resolvedPages.length) {
|
|
389
405
|
out.push('\n## Pages\n');
|
|
390
406
|
for (const page of resolvedPages) {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
407
|
+
const title = escapeMarkdownInline(page.title);
|
|
408
|
+
const url = escapeMarkdownUrl(page.url);
|
|
409
|
+
const desc =
|
|
410
|
+
page.description !== undefined
|
|
411
|
+
? `: ${escapeMarkdownInline(page.description)}`
|
|
412
|
+
: '';
|
|
413
|
+
out.push(`- [${title}](${url})${desc}`);
|
|
394
414
|
}
|
|
395
415
|
}
|
|
396
416
|
return out.join('\n') + '\n';
|
package/src/compiler/ssg.ts
CHANGED
|
@@ -98,6 +98,16 @@ export async function prerenderStaticParams(cfg: ResolvedToilConfig): Promise<st
|
|
|
98
98
|
const paramSets = await mod.generateStaticParams();
|
|
99
99
|
for (const params of paramSets) {
|
|
100
100
|
const url = fillPattern(route.pattern, params);
|
|
101
|
+
// Containment guard: a param value with `..`/separators could make `url` resolve the
|
|
102
|
+
// output path outside `outDir`. Resolve the target up front and skip anything that
|
|
103
|
+
// escapes, so a (possibly externally-derived) param can't clobber files elsewhere.
|
|
104
|
+
const target = path.join(outDir, url.replace(/^\//, ''), 'index.html');
|
|
105
|
+
const outRoot = path.resolve(outDir);
|
|
106
|
+
const absTarget = path.resolve(target);
|
|
107
|
+
if (absTarget !== outRoot && !absTarget.startsWith(outRoot + path.sep)) {
|
|
108
|
+
warn(`skipped ${route.pattern}: params escape outDir (${url})`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
101
111
|
let metadata: Record<string, unknown> | null = null;
|
|
102
112
|
try {
|
|
103
113
|
if (typeof mod.generateMetadata === 'function') {
|
|
@@ -114,7 +124,6 @@ export async function prerenderStaticParams(cfg: ResolvedToilConfig): Promise<st
|
|
|
114
124
|
warn(`metadata failed for ${url} (${err instanceof Error ? err.message : String(err)})`);
|
|
115
125
|
}
|
|
116
126
|
const html = injectSeoHtml(shell, routeSeo(cfg.seo, metadata, url));
|
|
117
|
-
const target = path.join(outDir, url.replace(/^\//, ''), 'index.html');
|
|
118
127
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
119
128
|
fs.writeFileSync(target, html);
|
|
120
129
|
generated.push(url);
|
package/src/compiler/vite.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import { createRequire } from 'node:module';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { pathToFileURL } from 'node:url';
|
|
@@ -41,6 +42,36 @@ const IMAGE_EXT = /^(png|jpe?g|svg|gif|tiff|bmp|ico|webp|avif)$/i;
|
|
|
41
42
|
/** Font extensions routed to `fonts/`. */
|
|
42
43
|
const FONT_EXT = /^(woff|woff2|eot|ttf|otf)$/i;
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Resolves bare `shared/*` imports to the project's `shared/` folder (replacing a plain alias so it
|
|
47
|
+
* can run early), and fails `shared/server` with an actionable message when the module has not been
|
|
48
|
+
* generated yet (it comes from the server build, which must run first), instead of Vite's opaque
|
|
49
|
+
* `UNLOADABLE_DEPENDENCY`.
|
|
50
|
+
*/
|
|
51
|
+
function sharedResolverPlugin(cfg: ResolvedToilConfig): PluginOption {
|
|
52
|
+
const sharedDir = path.join(cfg.root, 'shared');
|
|
53
|
+
return {
|
|
54
|
+
name: 'toiljs:shared-resolver',
|
|
55
|
+
enforce: 'pre',
|
|
56
|
+
resolveId(source: string) {
|
|
57
|
+
if (source !== 'shared' && !source.startsWith('shared/')) return null;
|
|
58
|
+
const rel = source === 'shared' ? 'index' : source.slice('shared/'.length);
|
|
59
|
+
for (const ext of ['', '.ts', '.tsx', '.js', '.jsx', '.mjs']) {
|
|
60
|
+
const candidate = path.join(sharedDir, rel + ext);
|
|
61
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
62
|
+
}
|
|
63
|
+
if (source === 'shared/server') {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'toiljs: "shared/server" is generated by the server build but is missing. ' +
|
|
66
|
+
'Run the server build first (it emits shared/server.ts from your @data/@remote code): ' +
|
|
67
|
+
'`npm run build:server` (toilscript --target release --rpcModule shared/server.ts).',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
44
75
|
/** Routes a built asset to a typed sub-folder (`images/`, `fonts/`, `css/`, else `assets/`). */
|
|
45
76
|
function assetFileName(name: string): string {
|
|
46
77
|
const ext = name.split('.').pop() ?? '';
|
|
@@ -116,12 +147,15 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
|
|
|
116
147
|
cfg.fonts ? fontPreloadPlugin(cfg) : undefined,
|
|
117
148
|
nodePolyfills({ globals: { Buffer: true, global: true, process: true } }),
|
|
118
149
|
react(),
|
|
150
|
+
sharedResolverPlugin(cfg),
|
|
119
151
|
toilPlugin(cfg),
|
|
120
152
|
],
|
|
121
153
|
resolve: {
|
|
122
154
|
alias: {
|
|
123
155
|
'toiljs/client': cfg.runtimePath,
|
|
124
156
|
'toiljs/routes': path.join(cfg.toilDir, 'routes.ts'),
|
|
157
|
+
// `shared/*` is resolved by sharedResolverPlugin (above) so a missing generated
|
|
158
|
+
// shared/server.ts gives an actionable error instead of an opaque load failure.
|
|
125
159
|
...polyfillShimAliases,
|
|
126
160
|
},
|
|
127
161
|
dedupe: ['react', 'react-dom'],
|