From c2d6271b9ab5ede756c788b45bea08781ecb1552 Mon Sep 17 00:00:00 2001 From: Exelo Date: Fri, 30 Oct 2026 15:15:16 +0100 Subject: [PATCH 1/2] feat: add online handling --- .env.example | 5 ++ CLAUDE.md | 3 + apps/editor/e2e/test.ts | 8 +- apps/editor/server/main.ts | 13 ++- coverage-floors.json | 10 +-- docs/docs/editor/files.mdx | 22 +++--- .../create-your-first-project.mdx | 2 + .../apps/client/src/components/position.ts | 2 +- .../breakout/apps/client/src/scenes/levels.ts | 3 +- packages/rpc/src/router/rpc-session.ts | 32 ++++++-- packages/rpc/test/rpc.spec.ts | 33 +++++++- packages/server-core/src/api/api-client.ts | 40 +++++++--- .../server-core/src/env/editor-env.type.ts | 2 + packages/server-core/src/env/env.schema.ts | 6 ++ packages/server-core/src/env/load-env.ts | 6 ++ packages/server-core/src/fs/archive.ts | 68 ++++++++++++---- packages/server-core/src/fs/fs-service.ts | 19 +++++ packages/server-core/src/http/read-body.ts | 32 ++++++++ packages/server-core/src/http/shell-csp.ts | 35 ++++++++ .../src/project/project-registry.ts | 6 +- .../src/server/create-editor-server.ts | 50 ++++++++++-- packages/server-core/src/server/index.ts | 1 + .../server-core/src/server/launch-token.ts | 55 +++++++++++++ .../server-core/src/session/session-store.ts | 32 ++++++-- .../server-core/test/api/api-client.spec.ts | 26 ++++++ .../server-core/test/fs/fs-service.spec.ts | 31 +++++++- .../server-core/test/http/read-body.spec.ts | 34 ++++++++ .../server-core/test/http/shell-csp.spec.ts | 29 +++++++ .../test/server/create-editor-server.spec.ts | 5 +- .../test/server/launch-token.spec.ts | 79 +++++++++++++++++++ .../test/session/session-store.spec.ts | 49 ++++++++++++ 31 files changed, 667 insertions(+), 71 deletions(-) create mode 100644 packages/server-core/src/http/read-body.ts create mode 100644 packages/server-core/src/http/shell-csp.ts create mode 100644 packages/server-core/src/server/launch-token.ts create mode 100644 packages/server-core/test/api/api-client.spec.ts create mode 100644 packages/server-core/test/http/read-body.spec.ts create mode 100644 packages/server-core/test/http/shell-csp.spec.ts create mode 100644 packages/server-core/test/server/launch-token.spec.ts diff --git a/.env.example b/.env.example index a489bec6..72c202b3 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,11 @@ # Files of the tutorial's project (_Help › Tutorial_), when not next to the editor build. # TUTORIAL_DIR= +# A local editor answers only to a browser that opened its link with this token once (other +# users of a shared machine can reach its loopback port). random: a new token at each +# start, printed with the link. Unset: no token (a machine of your own). +# EDITOR_TOKEN= + # Lets a local (OFFLINE) editor listen on a non-loopback HOST. It has no sign-in and runs # project code: only set it behind something that restricts who can connect. # ALLOW_REMOTE_OFFLINE= diff --git a/CLAUDE.md b/CLAUDE.md index c06f6a3f..14486252 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,9 @@ pnpm e2e:last-failure # error-context.md of the last failed test, its last fra - `/kit` (dev server only, 404 in a build) shows every kit component in both themes, and the floating layers' order over a dialog. - E2E specs open projects with `openProject(page, path)` (`e2e/helpers.ts`): `/load?path=` asks before opening a folder that is not an exact recent path (a relative path always asks), and the helper answers **Trust and open**. Server and runtime unit tests open projects with `openTrustedProject` (`@nanoforge-dev/editor-server-core/testing`): a fresh data folder trusts nothing. - The editor server answers only to loopback host names and `ALLOWED_HOSTS` (DNS rebinding guard): a dev server reached by another name needs it. +- `EDITOR_TOKEN` (off by default; `random` makes one per start): a local editor then answers only to a browser that opened the link it prints (`?token=`, which sets an HttpOnly cookie). Dev server and e2e run without it. +- The editor's page has a Content Security Policy (`packages/server-core/src/http/shell-csp.ts`; inline scripts by hash, read from `index.html`). A local editor allows `'unsafe-eval'` (the engine's ECS is Emscripten WebAssembly, whose bindings compile strings); a hosted one does not. The e2e fixture fails a test on any CSP violation: a new kind of resource (another origin, a worker type) needs the policy changed with it. +- Hosted sessions: project grants belong to the user who opened the project (a new user or a sign-out drops them); refused tokens are not re-checked for a minute; API calls time out after 15 s; over the session cap, anonymous sessions go first. `/rpc` bodies are capped at 100 MiB, folder zips are streamed (no trash, no `nf_modules`, 1 GiB at most), and trash entries older than 30 days go when a project opens. - To debug a failed e2e test, `pnpm e2e:last-failure` (or read `test-results//error-context.md`, and the last frame of `trace.zip`). A minified Monaco stack: rebuild code-editor with `npx vite build --sourcemap --outDir ` (same file hashes) and map the frames with `@jridgewell/trace-mapping`. - The CLI repo needs `pnpm --filter @nanoforge-dev/config build` before `pnpm build`, and `pnpm --filter @nanoforge-dev/schematics build` for `nf new` (its root build leaves the schematics out). diff --git a/apps/editor/e2e/test.ts b/apps/editor/e2e/test.ts index 124c76fc..122c3370 100644 --- a/apps/editor/e2e/test.ts +++ b/apps/editor/e2e/test.ts @@ -7,7 +7,8 @@ const IGNORED = new Set(['Canceled']); /** * The `test` of every spec: it fails when a page of the test throws an uncaught error - * (a Svelte effect loop or a rejected promise doesn't break what the test looks at). + * (a Svelte effect loop or a rejected promise doesn't break what the test looks at), or when its + * Content Security Policy blocks something. */ export const test = base.extend<{ pageErrors: string[]; stopsTheGame: undefined }>({ pageErrors: [ @@ -17,6 +18,11 @@ export const test = base.extend<{ pageErrors: string[]; stopsTheGame: undefined context.on('console', (message) => console.log(`[page ${message.type()}] ${message.text()}`), ); + /** The page's Content Security Policy blocked something the editor or a game needed. */ + context.on('console', (message) => { + if (message.type() === 'error' && /Content Security Policy/.test(message.text())) + errors.push(message.text()); + }); context.on('weberror', (webError) => { const { message, stack } = webError.error(); if (IGNORED.has(message)) return; diff --git a/apps/editor/server/main.ts b/apps/editor/server/main.ts index 4c5b12ce..520853e0 100644 --- a/apps/editor/server/main.ts +++ b/apps/editor/server/main.ts @@ -5,7 +5,12 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { type SocketData, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; +import { + type SocketData, + createEditorServer, + linkWithToken, + loadEnv, +} from '@nanoforge-dev/editor-server-core'; import pkg from '../package.json' with { type: 'json' }; @@ -42,7 +47,11 @@ const server = Bun.serve({ editor.logs .getLogger('server') - .info(`NanoForge editor ${pkg.version} (${env.mode}) on ${server.url.href}`); + .info( + `NanoForge editor ${pkg.version} (${env.mode}) on ${ + env.launchToken ? linkWithToken(server.url.href, env.launchToken) : server.url.href + }`, + ); const shutdown = () => { editor.dispose(); diff --git a/coverage-floors.json b/coverage-floors.json index 8bbaff82..07bbe6b5 100644 --- a/coverage-floors.json +++ b/coverage-floors.json @@ -120,10 +120,10 @@ "branches": 93 }, "@nanoforge-dev/editor-rpc": { - "lines": 86, - "statements": 81, - "functions": 82, - "branches": 69 + "lines": 89, + "statements": 84, + "functions": 85, + "branches": 73 }, "@nanoforge-dev/editor-runtime": { "lines": 87, @@ -141,7 +141,7 @@ "lines": 85, "statements": 82, "functions": 78, - "branches": 72 + "branches": 73 }, "@nanoforge-dev/editor-settings": { "lines": 76, diff --git a/docs/docs/editor/files.mdx b/docs/docs/editor/files.mdx index 183d16e5..0d61c23d 100644 --- a/docs/docs/editor/files.mdx +++ b/docs/docs/editor/files.mdx @@ -16,17 +16,17 @@ The **+** button, or right-click a folder and **New**: a file, a folder, or a te ## Organize -| To | Do | -| ------------ | ----------------------------------------------------------------- | -| Rename | `F2`, or right-click › Rename | -| Move | Drag the file onto a folder, or cut and paste | -| Copy | `Ctrl+C` then `Ctrl+V`; `Ctrl+D` duplicates in place | -| Delete | `Delete`. A folder or several files ask first. | -| Import | Drop files from your computer onto a folder, or the import button | -| Download | Right-click › Download. A folder downloads as a `.zip`. | -| Show on disk | Right-click › Reveal in file manager (local editors) | - -Every one of these can be undone with `Ctrl+Z` while the Files panel has focus. A deleted file goes to the editor's trash and comes back on undo. +| To | Do | +| ------------ | --------------------------------------------------------------------------------------------- | +| Rename | `F2`, or right-click › Rename | +| Move | Drag the file onto a folder, or cut and paste | +| Copy | `Ctrl+C` then `Ctrl+V`; `Ctrl+D` duplicates in place | +| Delete | `Delete`. A folder or several files ask first. | +| Import | Drop files from your computer onto a folder, or the import button | +| Download | Right-click › Download. A folder downloads as a `.zip` (without `nf_modules`, 1 GiB at most). | +| Show on disk | Right-click › Reveal in file manager (local editors) | + +Every one of these can be undone with `Ctrl+Z` while the Files panel has focus. A deleted file goes to the editor's trash and comes back on undo; the trash is emptied of what was deleted over 30 days ago. ## Search diff --git a/docs/docs/getting-started/create-your-first-project.mdx b/docs/docs/getting-started/create-your-first-project.mdx index 8d89008c..24ddfa27 100644 --- a/docs/docs/getting-started/create-your-first-project.mdx +++ b/docs/docs/getting-started/create-your-first-project.mdx @@ -29,6 +29,8 @@ nf editor my-game # starts the editor and opens the project in your brows A local editor can do what a hosted one cannot: run the game, use Git with your own credentials, and install plugins and packages. +On a computer other people use too, start it with `EDITOR_TOKEN=random`: the editor then answers only to the link it prints when it starts (it ends with `?token=…`), so the others can't reach your projects through its port. + ## The tutorial New to the editor? Its home page offers **Start the tutorial** (and _Help › Tutorial_ plays it again at any time). A local editor makes a small game, `my-first-game`, in the folder it works on: an orange ball that should bounce, and one mistake that keeps it still. A tour then walks you through the editor while you fix it: the screens, adding an entity, changing a value, fixing the mistake from **Problems**, playing the game, looking into it with **World**, and a first Git commit. diff --git a/examples/breakout/apps/client/src/components/position.ts b/examples/breakout/apps/client/src/components/position.ts index a322a390..84870d28 100644 --- a/examples/breakout/apps/client/src/components/position.ts +++ b/examples/breakout/apps/client/src/components/position.ts @@ -8,7 +8,7 @@ export class Position { name = "Position"; constructor( - /** From the left. */ + /** From the left. @color #f66151 */ public x = 0, /** From the top. */ public y = 0, diff --git a/examples/breakout/apps/client/src/scenes/levels.ts b/examples/breakout/apps/client/src/scenes/levels.ts index 7b058e4a..9253af90 100644 --- a/examples/breakout/apps/client/src/scenes/levels.ts +++ b/examples/breakout/apps/client/src/scenes/levels.ts @@ -14,6 +14,7 @@ import { GameOver } from "./game-over"; import { Pause } from "./pause"; import { Run } from "./run"; import { Victory } from "./victory"; +import { Ball } from "../components/ball"; /** * What every level has: its parent, `Run`, and the level after it. Levels are siblings under @@ -120,7 +121,7 @@ export class Level1 extends Level { new Visual(new Rect({ width: 141, height: 26, fill: "#eb4d4b", cornerRadius: 4 })), ); const brick6 = registry.spawnEntity(); - registry.addComponent(brick6, new Position(793, 130)); + registry.addComponent(brick6, new Position(795, 132)); registry.addComponent(brick6, new Box(141, 26)); registry.addComponent(brick6, new Brick(1, 10)); registry.addComponent( diff --git a/packages/rpc/src/router/rpc-session.ts b/packages/rpc/src/router/rpc-session.ts index 5f61e516..12a965b9 100644 --- a/packages/rpc/src/router/rpc-session.ts +++ b/packages/rpc/src/router/rpc-session.ts @@ -7,11 +7,14 @@ import { type StreamEntry, toRpcError } from './router-entries'; import type { CallInfo, RouterOptions, StreamCleanup, StreamSink } from './router.type'; interface Subscription { + readonly id: number; readonly controller: AbortController; readonly coalesce: boolean; queue: string[]; cleanup?: StreamCleanup; closed: boolean; + /** Ended (or failed) while events still waited: it goes once they and the end are sent. */ + finishing?: boolean; } export class RpcSession implements Disposable { @@ -58,6 +61,8 @@ export class RpcSession implements Disposable { while (subscription.queue.length && !this._blocked) { this._blocked = !this._send(subscription.queue.shift()!); } + if (!subscription.queue.length && subscription.finishing) + this._subscriptions.delete(subscription.id); if (this._blocked) return; } } @@ -78,6 +83,7 @@ export class RpcSession implements Disposable { }; if (this._subscriptions.has(message.id)) return; const subscription: Subscription = { + id: message.id, controller: new AbortController(), coalesce: entry?.definition.coalesce ?? false, queue: [], @@ -93,10 +99,7 @@ export class RpcSession implements Disposable { if (subscription.controller.signal.aborted) return; this._emit(message.id, subscription, entry.definition.event.parse(event)); }, - end: () => { - this._write({ t: 'end', id: message.id }); - this._close(message.id); - }, + end: () => this._finish(message.id, { t: 'end', id: message.id }), error: (error) => this._fail(message.id, error, call), }; const cleanup = await entry.handler( @@ -131,8 +134,25 @@ export class RpcSession implements Disposable { private _fail(id: number, error: unknown, call: CallInfo): void { const rpcError = toRpcError(error); if (rpcError.code === 'INTERNAL') this._options.onError?.(error, call); - this._write({ t: 'error', id, error: rpcError.toJSON() }); - this._close(id); + this._finish(id, { t: 'error', id, error: rpcError.toJSON() }); + } + + /** + * Ends a subscription with its last message. Events still queued by back-pressure go first: + * the end waits behind them, and the subscription goes once they are sent. + */ + private _finish(id: number, message: ServerMessage): void { + const subscription = this._subscriptions.get(id); + if (!subscription?.queue.length || subscription.finishing) { + this._write(message); + this._close(id); + return; + } + subscription.queue.push(JSON.stringify(message)); + subscription.finishing = true; + subscription.closed = true; + subscription.controller.abort(); + disposeCleanup(subscription.cleanup); } private _write(message: ServerMessage): void { diff --git a/packages/rpc/test/rpc.spec.ts b/packages/rpc/test/rpc.spec.ts index 8a415cfb..d70965f0 100644 --- a/packages/rpc/test/rpc.spec.ts +++ b/packages/rpc/test/rpc.spec.ts @@ -26,6 +26,7 @@ const Files = defineContract('fs', { const createRouter = () => { const emitters = new Map void>(); + const enders = new Map void>(); let emitStatus: (n: number) => void = () => undefined; const cleanup = vi.fn(); const internal = vi.fn(); @@ -46,6 +47,7 @@ const createRouter = () => { streams: { changes: ({ glob }, _context, sink) => { emitters.set(glob, (path) => sink.emit({ path })); + enders.set(glob, () => sink.end()); return toDisposable(() => { emitters.delete(glob); cleanup(glob); @@ -56,7 +58,14 @@ const createRouter = () => { }, }, }); - return { router, emitters, cleanup, internal, emitStatus: (n: number) => emitStatus(n) }; + return { + router, + emitters, + enders, + cleanup, + internal, + emitStatus: (n: number) => emitStatus(n), + }; }; describe('RPC calls', () => { @@ -162,6 +171,28 @@ describe('RPC streams', () => { expect(values).toEqual([1, 3]); }); + it('ends a stream after the events back-pressure held back', async () => { + const { router, emitters, enders, cleanup } = createRouter(); + const { client, sockets } = connect(router); + const received: string[] = []; + client.subscribe(Files, 'changes', { glob: '*' }, (event) => received.push(event.path), { + onEnd: () => received.push('end'), + }); + await flush(); + const socket = sockets[0]!; + socket.backpressure = true; + emitters.get('*')!('a.ts'); + emitters.get('*')!('b.ts'); + emitters.get('*')!('c.ts'); + enders.get('*')!(); + expect(cleanup).toHaveBeenCalledWith('*'); + socket.backpressure = false; + socket.session!.drain(); + await flush(); + expect(received).toEqual(['a.ts', 'b.ts', 'c.ts', 'end']); + expect(socket.session!.subscriptionCount).toBe(0); + }); + it('closes dead connections through the heartbeat', async () => { vi.useFakeTimers(); const { router } = createRouter(); diff --git a/packages/server-core/src/api/api-client.ts b/packages/server-core/src/api/api-client.ts index 8176f17c..5588ef46 100644 --- a/packages/server-core/src/api/api-client.ts +++ b/packages/server-core/src/api/api-client.ts @@ -6,6 +6,9 @@ import { RpcError, type RpcErrorCode } from '@nanoforge-dev/editor-rpc'; import type { EditorEnv } from '../env/editor-env.type'; import type { AuthTokens, Session } from '../session/session-store'; +/** How long a call to the NanoForge API may take: a request waiting on it waits no longer. */ +const API_TIMEOUT_MS = 15_000; + const API_STATUS_CODES: Record = { 400: 'BAD_REQUEST', 401: 'UNAUTHORIZED', @@ -97,6 +100,7 @@ export class ApiClient { constructor( private readonly _env: EditorEnv, private readonly _fetch: typeof fetch = globalThis.fetch.bind(globalThis), + private readonly _timeoutMs = API_TIMEOUT_MS, ) {} async request(session: Session | null, path: string, request: ApiRequest = {}): Promise { @@ -179,20 +183,34 @@ export class ApiClient { return session.tokens; } - private _send(path: string, request: ApiRequest, accessToken: string | null): Promise { + private async _send( + path: string, + request: ApiRequest, + accessToken: string | null, + ): Promise { const url = new URL(path, this._env.apiUrl); for (const [key, value] of Object.entries(request.query ?? {})) { if (value !== undefined) url.searchParams.set(key, String(value)); } - return this._fetch(url, { - method: request.method ?? 'GET', - headers: { - 'content-type': 'application/json', - ...(this._env.apiKey && { 'api-key': this._env.apiKey }), - ...(accessToken && { authorization: `Bearer ${accessToken}` }), - }, - ...(request.body !== undefined && { body: JSON.stringify(request.body) }), - ...(request.signal && { signal: request.signal }), - }); + const timeout = AbortSignal.timeout(this._timeoutMs); + try { + return await this._fetch(url, { + method: request.method ?? 'GET', + headers: { + 'content-type': 'application/json', + ...(this._env.apiKey && { 'api-key': this._env.apiKey }), + ...(accessToken && { authorization: `Bearer ${accessToken}` }), + }, + ...(request.body !== undefined && { body: JSON.stringify(request.body) }), + signal: request.signal ? AbortSignal.any([request.signal, timeout]) : timeout, + }); + } catch (error) { + if (timeout.aborted) + throw new RpcError( + 'UNAVAILABLE', + `NanoForge API ${request.method ?? 'GET'} ${path} did not answer in ${Math.round(this._timeoutMs / 1000)} s`, + ); + throw error; + } } } diff --git a/packages/server-core/src/env/editor-env.type.ts b/packages/server-core/src/env/editor-env.type.ts index 5d2d5195..a8efb929 100644 --- a/packages/server-core/src/env/editor-env.type.ts +++ b/packages/server-core/src/env/editor-env.type.ts @@ -24,6 +24,8 @@ export interface EditorEnv { readonly allowedHosts: readonly string[]; readonly devPlugins: readonly string[]; readonly bundledPluginsDir: string | undefined; + /** The token a local editor asks for (`EDITOR_TOKEN`), undefined when it asks none. */ + readonly launchToken: string | undefined; /** Files the tutorial writes over a new project (`tutorial/` next to the server). */ readonly tutorialDir: string | undefined; } diff --git a/packages/server-core/src/env/env.schema.ts b/packages/server-core/src/env/env.schema.ts index 046970f7..3f166c8c 100644 --- a/packages/server-core/src/env/env.schema.ts +++ b/packages/server-core/src/env/env.schema.ts @@ -79,6 +79,12 @@ export const EnvSchema = z BUNDLED_PLUGINS_DIR: optionalString, /** Files of the tutorial's project (_Help › Tutorial_), when not next to the editor build. */ TUTORIAL_DIR: optionalString, + /** + * A local editor answers only to a browser that opened its link with this token once (other + * users of a shared machine can reach its loopback port). `random`: a new token at each + * start, printed with the link. Unset: no token (a machine of your own). + */ + EDITOR_TOKEN: optionalString, /** * Lets a local (OFFLINE) editor listen on a non-loopback `HOST`. It has no sign-in and runs * project code: only set it behind something that restricts who can connect. diff --git a/packages/server-core/src/env/load-env.ts b/packages/server-core/src/env/load-env.ts index e6b23f82..50ccce99 100644 --- a/packages/server-core/src/env/load-env.ts +++ b/packages/server-core/src/env/load-env.ts @@ -40,5 +40,11 @@ export const loadEnv = ( devPlugins: env.DEV_PLUGINS.map(absolute), bundledPluginsDir: env.BUNDLED_PLUGINS_DIR && absolute(env.BUNDLED_PLUGINS_DIR), tutorialDir: env.TUTORIAL_DIR && absolute(env.TUTORIAL_DIR), + launchToken: + env.PUBLIC_MODE !== 'OFFLINE' || !env.EDITOR_TOKEN + ? undefined + : env.EDITOR_TOKEN === 'random' + ? randomBytes(24).toString('base64url') + : env.EDITOR_TOKEN, }; }; diff --git a/packages/server-core/src/fs/archive.ts b/packages/server-core/src/fs/archive.ts index 4834e4dd..0a2e1287 100644 --- a/packages/server-core/src/fs/archive.ts +++ b/packages/server-core/src/fs/archive.ts @@ -1,25 +1,61 @@ -import { zip } from 'fflate'; +import { Zip, ZipDeflate } from 'fflate'; import { readFile } from 'node:fs/promises'; -import type { ProjectFileSystem } from './fs-service'; +import { RpcError } from '@nanoforge-dev/editor-rpc'; -/** Zips a folder of the project (entries hidden by `.nfignore` stay out). */ +import { PACKAGES_DIR, type ProjectFileSystem, TRASH_DIR } from './fs-service'; + +/** Bytes a folder may hold to be downloaded as a .zip (what its files add up to). */ +export const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024; + +/** Left out of archives: deleted files, and installed packages (installed again from the lock). */ +const LEFT_OUT = [TRASH_DIR, PACKAGES_DIR]; +const leftOut = (path: string) => + LEFT_OUT.some((folder) => path === folder || path.startsWith(`${folder}/`)); + +/** + * Zips a folder of the project as a stream: one file read at a time, as the reader asks for + * more, so a large folder is never held in memory. Entries hidden by `.nfignore`, the trash and + * `nf_modules` stay out; a folder over `maxBytes` is refused (`PAYLOAD_TOO_LARGE`). + */ export const archiveFolder = async ( fs: ProjectFileSystem, path: string, -): Promise> => { - const entries = await fs.list(path, true); - const files: Record = {}; + maxBytes = MAX_ARCHIVE_BYTES, +): Promise> => { const prefix = path ? `${path}/` : ''; - for (const entry of entries) { - if (entry.kind !== 'file') continue; - files[entry.path.slice(prefix.length)] = new Uint8Array( - await readFile(await fs.jail.resolve(entry.path)), - ); - } - return new Promise((resolve, reject) => - zip(files, { level: 6 }, (error, data) => - error ? reject(error) : resolve(data as Uint8Array), - ), + const files = (await fs.list(path, true)).filter( + (entry) => entry.kind === 'file' && !leftOut(entry.path), ); + const total = files.reduce((sum, entry) => sum + entry.size, 0); + if (total > maxBytes) + throw new RpcError( + 'PAYLOAD_TOO_LARGE', + `${path || 'The project'} holds ${Math.round(total / 1024 / 1024)} MiB: download a smaller folder`, + ); + + let next = 0; + let zip: Zip | undefined; + return new ReadableStream({ + start(controller) { + zip = new Zip((error, chunk, final) => { + if (error) return controller.error(error); + controller.enqueue(chunk); + if (final) controller.close(); + }); + }, + async pull() { + const entry = files[next++]; + if (!entry) { + zip!.end(); + return; + } + const file = new ZipDeflate(entry.path.slice(prefix.length), { level: 6 }); + zip!.add(file); + file.push(new Uint8Array(await readFile(await fs.jail.resolve(entry.path))), true); + }, + cancel() { + zip?.terminate(); + }, + }); }; diff --git a/packages/server-core/src/fs/fs-service.ts b/packages/server-core/src/fs/fs-service.ts index d8ac5df9..90835229 100644 --- a/packages/server-core/src/fs/fs-service.ts +++ b/packages/server-core/src/fs/fs-service.ts @@ -24,6 +24,8 @@ export const NFIGNORE_FILE = '.nfignore'; /** Installed bundles: usable, never modified (copy an entry into the project to change it). */ export const PACKAGES_DIR = 'nf_modules'; const EDITOR_GITIGNORE = 'trash/\nlocal.json\n'; +/** Deleted entries are kept this long in the trash (their name starts with when they went). */ +export const TRASH_TTL_MS = 30 * 24 * 3600 * 1000; export const MAX_READ_BYTES = 64 * 1024 * 1024; /** Bytes `readMany` sends in one answer (a file larger than this still goes, alone). */ @@ -320,6 +322,23 @@ export class ProjectFileSystem { return { trashPath: `${TRASH_DIR}/${basename(target)}` }; } + /** + * Empties the trash of entries deleted more than `TRASH_TTL_MS` ago (their undo can no longer + * bring them back). Returns how many went. + */ + async purgeTrash(now = Date.now()): Promise { + const trash = await this.jail.resolve(TRASH_DIR); + const names = await readdir(trash).catch(() => [] as string[]); + let purged = 0; + for (const name of names) { + const deletedAt = Number(/^(\d+)-/.exec(name)?.[1]); + if (!Number.isFinite(deletedAt) || now - deletedAt <= TRASH_TTL_MS) continue; + await rm(join(trash, name), { recursive: true, force: true }); + purged++; + } + return purged; + } + /** Brings a deleted entry back from the trash (undo of `delete`). */ async restore(trashPath: string, to: string): Promise { if ( diff --git a/packages/server-core/src/http/read-body.ts b/packages/server-core/src/http/read-body.ts new file mode 100644 index 00000000..014817f2 --- /dev/null +++ b/packages/server-core/src/http/read-body.ts @@ -0,0 +1,32 @@ +/** Bytes a call to `/rpc` may carry: a 64 MiB file written in base64, and the rest of the call. */ +export const MAX_RPC_BODY_BYTES = 100 * 1024 * 1024; + +/** + * A request's body as text, or undefined once it is over `maxBytes` (read no further). The + * announced length is checked first; a body without one is counted as it comes. + */ +export const readBody = async (request: Request, maxBytes: number): Promise => { + const announced = Number(request.headers.get('content-length')); + if (Number.isFinite(announced) && announced > maxBytes) return undefined; + if (!request.body) return ''; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + await reader.cancel(); + return undefined; + } + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +}; diff --git a/packages/server-core/src/http/shell-csp.ts b/packages/server-core/src/http/shell-csp.ts new file mode 100644 index 00000000..65d6c5aa --- /dev/null +++ b/packages/server-core/src/http/shell-csp.ts @@ -0,0 +1,35 @@ +import { createHash } from 'node:crypto'; + +/** + * Content Security Policy of the editor's page. Scripts come from the editor (and the inline + * script SvelteKit writes, by its hash); a game played in the page may compile WebAssembly, use + * blob and data URLs, and talk to its server on another port of this machine. Nothing else may + * load scripts, frame the page, or receive its data. + * + * `games`: the page plays games (a local editor). The engine's ECS is WebAssembly built by + * Emscripten, whose bindings compile functions from strings: such a page needs `'unsafe-eval'`. + */ +export const shellCsp = (html: string, { games = false }: { games?: boolean } = {}): string => { + const hashes = [...html.matchAll(/`, + ); + const hash = createHash('sha256').update(inline).digest('base64'); + const directives = Object.fromEntries( + policy.split('; ').map((directive) => { + const [name, ...values] = directive.split(' '); + return [name, values]; + }), + ); + expect(directives['script-src']).toEqual(["'self'", `'sha256-${hash}'`, "'wasm-unsafe-eval'"]); + expect(directives['connect-src']).toContain('ws://localhost:*'); + expect(directives['frame-ancestors']).toEqual(["'self'"]); + expect(directives['default-src']).toEqual(["'self'"]); + }); + + it('lets a local editor play games, whose WebAssembly bindings compile strings', () => { + expect(shellCsp('', { games: true })).toContain("script-src 'self' 'unsafe-eval'"); + expect(shellCsp('')).not.toContain("'unsafe-eval'"); + }); +}); diff --git a/packages/server-core/test/server/create-editor-server.spec.ts b/packages/server-core/test/server/create-editor-server.spec.ts index 6eba6693..64d4c2dc 100644 --- a/packages/server-core/test/server/create-editor-server.spec.ts +++ b/packages/server-core/test/server/create-editor-server.spec.ts @@ -217,7 +217,10 @@ describe('editor server', () => { ).toBe(404); const shell = await get('/project/abc'); expect(await shell.text()).toBe('editor'); - expect(shell.headers.get('content-security-policy')).toBe("frame-ancestors 'self'"); + const policy = shell.headers.get('content-security-policy')!; + expect(policy).toContain("frame-ancestors 'self'"); + expect(policy).toMatch(/script-src 'self' '(unsafe-eval|wasm-unsafe-eval)'/); + expect(policy).toContain("object-src 'none'"); expect(shell.headers.get('x-frame-options')).toBe('SAMEORIGIN'); expect((await get('/_app/missing.js')).status).toBe(404); diff --git a/packages/server-core/test/server/launch-token.spec.ts b/packages/server-core/test/server/launch-token.spec.ts new file mode 100644 index 00000000..63857125 --- /dev/null +++ b/packages/server-core/test/server/launch-token.spec.ts @@ -0,0 +1,79 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { loadEnv } from '../../src/env/load-env'; +import { createEditorServer } from '../../src/server/create-editor-server'; +import { linkWithToken } from '../../src/server/launch-token'; +import { TEST_ORIGIN as ORIGIN } from '../../src/testing'; + +const folders: string[] = []; +const server = (EDITOR_TOKEN: string | undefined, PUBLIC_MODE = 'OFFLINE') => { + const workspace = mkdtempSync(join(tmpdir(), 'nf-token-')); + folders.push(workspace); + const staticDir = join(workspace, 'static'); + mkdirSync(staticDir, { recursive: true }); + writeFileSync(join(staticDir, 'index.html'), 'editor'); + const env = loadEnv( + { + NODE_ENV: 'test', + PUBLIC_MODE, + ...(PUBLIC_MODE === 'ONLINE' && { API_KEY: 'key' }), + FS_ROOT: workspace, + DATA_DIR: join(workspace, 'data'), + ...(EDITOR_TOKEN && { EDITOR_TOKEN }), + }, + workspace, + ); + return { env, editor: createEditorServer({ env, version: '0.0.0-test', staticDir }) }; +}; + +afterAll(() => { + for (const folder of folders) rmSync(folder, { recursive: true, force: true }); +}); + +describe('launch token', () => { + it('answers only a browser that opened the link with the token', async () => { + const { editor } = server('a-long-enough-token'); + const page = (path: string, headers: Record = {}) => + editor.fetch( + new Request(`${ORIGIN}${path}`, { headers: { accept: 'text/html', ...headers } }), + ); + + const refused = (await page('/project/abc'))!; + expect(refused.status).toBe(401); + expect(await refused.text()).toContain('?token='); + expect((await page('/project/abc?token=wrong'))!.status).toBe(401); + expect( + (await editor.fetch( + new Request(`${ORIGIN}/rpc/session.info`, { method: 'POST', body: 'null' }), + ))!.status, + ).toBe(401); + + const link = (await page('/project/abc?token=a-long-enough-token&tour=x'))!; + expect(link.status).toBe(303); + expect(link.headers.get('location')).toBe('/project/abc?tour=x'); + const cookie = link.headers.get('set-cookie')!; + expect(cookie).toMatch( + /^nf_editor_token=a-long-enough-token; Path=\/; HttpOnly; SameSite=Strict/, + ); + + const opened = (await page('/project/abc', { cookie: cookie.split(';')[0]! }))!; + expect(opened.status).toBe(200); + expect((await page('/healthz'))!.status).toBe(200); + editor.dispose(); + }); + + it('asks none without EDITOR_TOKEN, a new one with random, and none in a hosted editor', () => { + const none = server(undefined); + expect(none.env.launchToken).toBeUndefined(); + const random = server('random'); + expect(random.env.launchToken).toMatch(/^[\w-]{32}$/); + expect(linkWithToken('http://127.0.0.1:3000/', random.env.launchToken!)).toBe( + `http://127.0.0.1:3000/?token=${random.env.launchToken}`, + ); + expect(server('a-long-enough-token', 'ONLINE').env.launchToken).toBeUndefined(); + for (const { editor } of [none, random]) editor.dispose(); + }); +}); diff --git a/packages/server-core/test/session/session-store.spec.ts b/packages/server-core/test/session/session-store.spec.ts index c1ef441d..feea4fae 100644 --- a/packages/server-core/test/session/session-store.spec.ts +++ b/packages/server-core/test/session/session-store.spec.ts @@ -88,4 +88,53 @@ describe('SessionStore', () => { expect(first.session.user).toBeNull(); expect([...first.session.projects]).toEqual([]); }); + + it('takes the projects of a session away when its user changes or signs out', async () => { + const users: Record = { + 'access-ada': { id: 'ada', name: 'Ada' }, + 'access-bob': { id: 'bob', name: 'Bob' }, + }; + const store = new SessionStore(env, { + verify: async (session) => users[session.tokens?.accessToken ?? ''] ?? null, + }); + const first = await store.resolve('accessToken=access-ada; refreshToken=r'); + const cookie = cookieOf(first.setCookies); + first.session.projects.add('ada-project'); + + const same = await store.resolve(`${cookie}; accessToken=access-ada; refreshToken=r`); + expect(same.session.projects.has('ada-project')).toBe(true); + + const bob = await store.resolve(`${cookie}; accessToken=access-bob; refreshToken=r`); + expect(bob.session).toBe(first.session); + expect(bob.session.user?.id).toBe('bob'); + expect(bob.session.projects.size).toBe(0); + + bob.session.projects.add('bob-project'); + const out = await store.resolve(cookie); + expect(out.session.user).toBeNull(); + expect(out.session.projects.size).toBe(0); + }); + + it('asks the API once about tokens it refused, and keeps signed-in users over anonymous ones', async () => { + let asked = 0; + const store = new SessionStore(env, { + verify: async (session) => { + asked++; + return session.tokens?.accessToken === 'good' ? { id: 'ada', name: 'Ada' } : null; + }, + maxSessions: 2, + }); + const bad = await store.resolve('accessToken=bad; refreshToken=r'); + const cookie = cookieOf(bad.setCookies); + await store.resolve(`${cookie}; accessToken=bad; refreshToken=r`); + await store.resolve(`${cookie}; accessToken=bad; refreshToken=r`); + expect(asked).toBe(1); + const good = await store.resolve(`${cookie}; accessToken=good; refreshToken=r`); + expect(good.session.user?.id).toBe('ada'); + expect(asked).toBe(2); + + for (let index = 0; index < 5; index++) await store.resolve(null); + const again = await store.resolve(`${cookie}; accessToken=good; refreshToken=r`); + expect(again.session).toBe(good.session); + }); }); From 85b8094f6adf0ccdc9394bd5d85c3db2f9b0477c Mon Sep 17 00:00:00 2001 From: Exelo Date: Fri, 30 Oct 2026 21:34:13 +0100 Subject: [PATCH 2/2] refactor: split the changes tab and scene view - git: the changes tree keys move to changes-keys.ts - ecs: key nudges and camera move to key-nudges.ts and scene-camera.ts --- .../ecs/src/widgets/scene/SceneView.svelte | 87 ++++--------- plugins/ecs/src/widgets/scene/key-nudges.ts | 60 +++++++++ plugins/ecs/src/widgets/scene/scene-camera.ts | 45 +++++++ .../ecs/test/widgets/scene/key-nudges.spec.ts | 75 +++++++++++ .../test/widgets/scene/scene-camera.spec.ts | 24 ++++ .../git/src/widgets/commit/ChangesTab.svelte | 69 +++------- .../git/src/widgets/commit/changes-keys.ts | 79 ++++++++++++ .../test/widgets/commit/changes-keys.spec.ts | 118 ++++++++++++++++++ 8 files changed, 439 insertions(+), 118 deletions(-) create mode 100644 plugins/ecs/src/widgets/scene/key-nudges.ts create mode 100644 plugins/ecs/src/widgets/scene/scene-camera.ts create mode 100644 plugins/ecs/test/widgets/scene/key-nudges.spec.ts create mode 100644 plugins/ecs/test/widgets/scene/scene-camera.spec.ts create mode 100644 plugins/git/src/widgets/commit/changes-keys.ts create mode 100644 plugins/git/test/widgets/commit/changes-keys.spec.ts diff --git a/plugins/ecs/src/widgets/scene/SceneView.svelte b/plugins/ecs/src/widgets/scene/SceneView.svelte index 960c39ce..9631290d 100644 --- a/plugins/ecs/src/widgets/scene/SceneView.svelte +++ b/plugins/ecs/src/widgets/scene/SceneView.svelte @@ -7,7 +7,9 @@ import { LiveServiceToken } from '../../live/live-service'; import { type SceneShape, buildScene, withLivePositions } from '../../scene/build-scene'; import { panelServices } from '../../session/panel-services'; + import { type KeyMove, keyNudges, nudgeStep } from './key-nudges'; import { moveShape } from './move-shape'; + import { FIT, STAGE_HEIGHT, STAGE_WIDTH, isFitted, zoomAround, zoomOf } from './scene-camera'; /** The Scene screen hosting this view. */ const { instance }: { instance?: WidgetInstance } = $props(); @@ -28,10 +30,6 @@ const instances = live.instances; const liveSelection = live.selection; - /** Design resolution of the game (the engine's default viewport). */ - const WIDTH = 1920; - const HEIGHT = 1080; - const client = $derived($instances.find((candidate) => candidate.source === 'client')); const componentName = (item: ReturnType, className: string | undefined) => { const data = ecsData(item); @@ -64,20 +62,9 @@ }); /** The part of the world in view: the whole stage with a margin, until zoomed or panned. */ - const MARGIN = 0.05; - const FIT = { - x: -WIDTH * MARGIN, - y: -HEIGHT * MARGIN, - width: WIDTH * (1 + 2 * MARGIN), - height: HEIGHT * (1 + 2 * MARGIN), - }; - const MIN_ZOOM = 0.1; - const MAX_ZOOM = 16; let view = $state({ ...FIT }); - const zoom = $derived(FIT.width / view.width); - const fitted = $derived( - view.x === FIT.x && view.y === FIT.y && view.width === FIT.width && view.height === FIT.height, - ); + const zoom = $derived(zoomOf(view)); + const fitted = $derived(isFitted(view)); let root = $state(); let svg = $state(); @@ -105,18 +92,7 @@ const wheel = (node: SVGSVGElement) => { const onwheel = (event: WheelEvent) => { event.preventDefault(); - const point = toScene(event); - const next = Math.min( - MAX_ZOOM, - Math.max(MIN_ZOOM, zoom * (event.deltaY > 0 ? 1 / 1.15 : 1.15)), - ); - const ratio = zoom / next; - view = { - x: point.x - (point.x - view.x) * ratio, - y: point.y - (point.y - view.y) * ratio, - width: view.width * ratio, - height: view.height * ratio, - }; + view = zoomAround(view, toScene(event), event.deltaY); }; node.addEventListener('wheel', onwheel, { passive: false }); return { destroy: () => node.removeEventListener('wheel', onwheel) }; @@ -182,41 +158,24 @@ scene: $location?.scene, }); - /** The arrow keys move the selected entity by 1 unit, 10 with Shift. */ - const STEPS: Record = { - ArrowLeft: [-1, 0], - ArrowRight: [1, 0], - ArrowUp: [0, -1], - ArrowDown: [0, 1], - }; - /** Presses in a row are shown at once and written as one move once the keys rest. */ - let keyMove = $state<{ entity: string; dx: number; dy: number }>(); - let keyTimer: ReturnType | undefined; - const commitKeys = () => { - clearTimeout(keyTimer); - const current = keyMove; - keyMove = undefined; - const shape = current && shapes.find((candidate) => candidate.entity === current.entity); - if (current && shape) moveShape(moveContext(), shape, current.dx, current.dy); - }; - $effect(() => () => commitKeys()); + /** The arrow keys move the selected entity: presses in a row are written as one move. */ + let keyMove = $state(); + const nudges = keyNudges({ + read: () => keyMove, + write: (next) => (keyMove = next), + commit: (moved) => { + const shape = shapes.find((candidate) => candidate.entity === moved.entity); + if (shape) moveShape(moveContext(), shape, moved.dx, moved.dy); + }, + }); + $effect(() => () => nudges.flush()); const nudge = (event: KeyboardEvent) => { - const step = STEPS[event.key]; - if (!step || event.altKey || event.ctrlKey || event.metaKey) return; - const shape = shapes.find( - (candidate) => candidate.entity === selectedEntity && candidate.position, - ); - if (!shape) return; + const step = nudgeStep(event); + const shape = + step && shapes.find((candidate) => candidate.entity === selectedEntity && candidate.position); + if (!step || !shape) return; event.preventDefault(); - if (keyMove && keyMove.entity !== shape.entity) commitKeys(); - const size = event.shiftKey ? 10 : 1; - keyMove = { - entity: shape.entity, - dx: (keyMove?.dx ?? 0) + step[0] * size, - dy: (keyMove?.dy ?? 0) + step[1] * size, - }; - clearTimeout(keyTimer); - keyTimer = setTimeout(commitKeys, 400); + nudges.press(shape.entity, step); }; const offset = (shape: SceneShape) => { @@ -254,7 +213,7 @@ aria-keyshortcuts="ArrowLeft ArrowRight ArrowUp ArrowDown" tabindex="0" onkeydown={nudge} - onblur={commitKeys} + onblur={nudges.flush} onpointermove={move} onpointerup={end} onpointercancel={() => { @@ -262,7 +221,7 @@ pan = undefined; }} > - + {#each inheritedShapes as shape, index (`inherited:${shape.entity}:${index}`)}