Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<test>/error-context.md`, and the last frame of `trace.zip`). A minified Monaco stack: rebuild code-editor with `npx vite build --sourcemap --outDir <scratch>` (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).

Expand Down
8 changes: 7 additions & 1 deletion apps/editor/e2e/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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;
Expand Down
13 changes: 11 additions & 2 deletions apps/editor/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };

Expand Down Expand Up @@ -42,7 +47,11 @@ const server = Bun.serve<SocketData>({

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();
Expand Down
10 changes: 5 additions & 5 deletions coverage-floors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -141,7 +141,7 @@
"lines": 85,
"statements": 82,
"functions": 78,
"branches": 72
"branches": 73
},
"@nanoforge-dev/editor-settings": {
"lines": 76,
Expand Down
22 changes: 11 additions & 11 deletions docs/docs/editor/files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/docs/getting-started/create-your-first-project.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion examples/breakout/apps/client/src/components/position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion examples/breakout/apps/client/src/scenes/levels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 26 additions & 6 deletions packages/rpc/src/router/rpc-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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: [],
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 32 additions & 1 deletion packages/rpc/test/rpc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const Files = defineContract('fs', {

const createRouter = () => {
const emitters = new Map<string, (path: string) => void>();
const enders = new Map<string, () => void>();
let emitStatus: (n: number) => void = () => undefined;
const cleanup = vi.fn();
const internal = vi.fn();
Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down
40 changes: 29 additions & 11 deletions packages/server-core/src/api/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, RpcErrorCode> = {
400: 'BAD_REQUEST',
401: 'UNAUTHORIZED',
Expand Down Expand Up @@ -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<T>(session: Session | null, path: string, request: ApiRequest = {}): Promise<T> {
Expand Down Expand Up @@ -179,20 +183,34 @@ export class ApiClient {
return session.tokens;
}

private _send(path: string, request: ApiRequest, accessToken: string | null): Promise<Response> {
private async _send(
path: string,
request: ApiRequest,
accessToken: string | null,
): Promise<Response> {
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;
}
}
}
2 changes: 2 additions & 0 deletions packages/server-core/src/env/editor-env.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions packages/server-core/src/env/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions packages/server-core/src/env/load-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};
Loading
Loading