GioJSdocs
On this page

Upgrading

Move an app from GioJS 0.1.0-beta.7 to 0.1.0-beta.8 step by step: packages, scripts, gio.toml, routes, the new security defaults, caching, errors and deploys.

Beta.8 turns on production defaults - CSRF protection, security headers, bounded connections, strict configuration - and changes a few behaviors apps relied on. Most apps need only steps 1 to 4; the rest apply when you use the feature they name. Each step says what changed, how to tell whether it affects you, and what to change. The complete list is the release notes; what each new default protects, and how to turn it off, is in Turning Protections On and Off.

1. Update the packages

Install the new versions together: the server binary and @gio.js/core check each other's protocol version when the worker starts, and gio doctor reports @gio.js/* packages that are not in lockstep. Add @gio.js/core if it is not a direct dependency yet (types and server helpers are imported from it), and move cross-env to dependencies, since npm start now uses it in production:

npm install @gio.js/[email protected] @gio.js/[email protected] @gio.js/[email protected] cross-env

Building the server from source (instead of the published binary) needs Rust 1.89 or newer.

2. Fix the start script

What changed: the server decides the runtime mode, and the Node worker follows it - development only when the server starts with NODE_ENV=development, production otherwise. Beta.7 ran a development worker (dev bundles, source maps, error stacks) behind a production server when NODE_ENV was unset.

package.json
   "scripts": {
     "dev": "cross-env NODE_ENV=development giojs-server",
-    "start": "giojs-server"
+    "start": "cross-env NODE_ENV=production giojs-server"
   },

The new gio dev and gio start set the mode themselves. Bare gio no longer starts a server: it prints the help and exits with code 2. Replace it in scripts and Dockerfiles:

Dockerfile
- CMD ["npx", "gio"]
+ CMD ["npx", "gio", "start"]

The giojs-server binary now takes --check-config alone. Any other argument - --port 4000, --version - prints unexpected argument and exits with code 2 without starting; beta.7 ignored it and started. Set the port with GIO_PORT (or [server] port), and use gio --version for versions:

package.json
-    "start": "giojs-server --port 4000"
+    "start": "cross-env NODE_ENV=production GIO_PORT=4000 giojs-server"

3. Validate your configuration

What changed: an unknown section or key anywhere in gio.toml now stops startup with the file, the line and the closest valid key. gio.config.ts is validated too (unknown keys, plugins without a name), and a [[guards]] entry with a misspelled key, no requirement or an invalid path fails startup instead of being skipped. Run the check before you deploy. It never binds a port, and lists every validation problem at once - unknown keys and sections together with invalid values, broken rules and [i18n] mistakes. Only a TOML syntax error is reported on its own, and a required key whose value is invalid (path = 3) holds back the problems after it, so run the check again after fixing those:

bash
npx giojs-server --check-config     # JSON report; exit code 1 when startup would fail
npx gio doctor                       # the same check, plus Node, versions, tsconfig and the port
text
gio.toml:5: unknown key [image] - did you mean [images]?

Startup also refuses rules and settings that beta.7 skipped or ignored:

  • Rules that cannot be enforced. A [[redirects]], [[rewrites]] or [[headers]] rule that cannot be compiled - a relative pattern, a catch-all that is not last, an unknown capture, a bad status or header - stops startup, like a broken guard. A redirect or rewrite to, and a guard's redirect_to, must be a path on this site: //evil.com and /\evil.com, which a browser reads as another site, are refused. Send visitors to another site from a route handler.
    text
    gio.toml:1: invalid [[redirects]] entry for "/a": target "//evil.com" is another site (a browser reads a leading // or /\ as one): redirect to another site from a route handler
  • [i18n] is checked. An unknown detect_from value, an empty or duplicate locale, and a default_locale that is not one of a non-empty locales stop startup. default_locale defaults to "en", so an app that lists locales without en must now name its default:
    gio.toml
      [i18n]
      locales = ["de", "fr"]
    + default_locale = "de"
  • middleware.ts fails closed. A middleware.ts that throws while it loads, has no default export, or holds a rule that cannot be enforced (an invalid pattern, a malformed field, an unknown key, a guard without a requirement, a target that leaves the site) stops the worker at boot with every problem listed. In production the server exits with code 1; in development it waits for you to save a fix. Beta.7 dropped the rules - every rule, guards included, for a file that threw - with a warning and kept serving. --check-config does not load middleware.ts: start the app once (npm start) to check it.
    text
    giojs-server: the Node worker exited before it was ready (exit status: 1):
      /srv/shop/middleware.ts failed to load: GIO_SESSION_SECRET is not set

Keys that never did anything are now rejected with what to use instead:

gio.toml
  [cache]
- memory_mb = 256
+ memory_max_entries = 1000      # pages kept in memory

- [cache.redis]                  # no Redis backend exists yet: remove it
- url = "redis://cache:6379"

- [css]
- engine = "lightningcss"        # remove: there is one engine

- [prefetch]
- strategy = "hover"             # remove: choose per link with <GioLink prefetch>

- [my-tool]                      # settings for other tools: name the table x-...
+ [x-my-tool]
  option = true

Also check these, which changed meaning:

  • 0 now lifts a limit everywhere. In beta.7, [websocket] max_connections = 0 closed every socket, [websocket] ping_interval_secs = 0 crashed every connection, and [images] max_remote_bytes = 0 rejected every remote image. Each now means unlimited (no pings). If you used 0 to turn something off, use its switch:
    gio.toml
      [websocket]
    - max_connections = 0
    + enabled = false
    The [prefetch] keys are new in beta.8 - beta.7 ignored the section and always used the built-in budget - and follow the same rule: max_concurrent = 0 or max_per_second = 0 lifts that budget, and [prefetch] enabled = false turns prefetching off.
  • [server] max_body_bytes = 0 used to answer 413 to every body; it now means no limit of its own (the worker's message cap, about 48 MiB of binary body, still applies) and logs a warning.
  • A malformed [metrics] ip_allowlist entry stops startup, like a malformed trusted_proxies entry.
  • The page cache directory ([cache] disk_path or GIO_CACHE_DIR) may no longer be, contain or sit inside app/ or public/.

New apps' gio.toml starts with a schema line that gives editors completion and hover docs; add it to yours:

gio.toml
#:schema ./node_modules/@gio.js/server/gio.schema.json

4. Review your .env files

What changed: the server now loads .env.{mode}.local, .env.local, .env.{mode} and .env from the project root at startup, with variables already in the environment winning. A committed .env that was ignored before now applies - check what it sets. NODE_ENV in a file is ignored, and a file that cannot be parsed stops startup with its name and line. To keep the old behavior, turn loading off:

gio.toml
[env]
files = false        # or GIO_ENV_FILES=0 in the environment

Only GIO_PUBLIC_* variables reach browser code. See Environment Variables.

5. Check your routes

What changed: app/ follows the App Router folder rules. Run gio routes before and after upgrading and compare the URLs:

  • (group) folders no longer appear in URLs, and _private folders are never routed: move routes out of _-prefixed folders.
  • [...slug] matches one or more segments and [[...slug]] zero or more; the param is one /-joined string ('a/b', or '' for an optional catch-all that matched nothing):
    app/docs/[...slug]/page.tsx
    import type { GsspContext } from '@gio.js/core';
    
    export async function getServerSideProps(ctx: GsspContext<'/docs/*slug'>) {
      const parts = ctx.params.slug.split('/'); // /docs/guides/install -> ['guides', 'install']
      return { props: { parts } };
    }
  • Layouts come from the folder tree, so layouts inside dynamic folders and route groups now apply. Two files that answer the same URL fail startup, naming both.
  • public/ is served at the site root (/favicon.ico, /robots.txt) ahead of pages: a public file wins over a page with the same path. It defaults to the directory next to app/ (GIO_PUBLIC_DIR overrides it).
  • /_gio/ belongs to the framework: an app route there now answers 404.
  • Request paths are canonical: repeated and trailing slashes collapse before rules match, and paths with . or .. segments or a bad % escape get 400.
  • *rest in guards, redirects, rewrites and header rules now also matches zero segments: /admin/*rest covers /admin, and a [[rate_limits]] path /api/* covers /api.
  • A route.ts that throws while it is imported now answers 500 instead of 404.

6. Allow legitimate cross-site requests

What changed: CSRF protection is on. Cross-site POST, PUT, PATCH and DELETE requests from browsers get 403 before your code runs; cross-origin WebSocket upgrades get 403 too. Requests without browser headers - curl, server-to-server webhooks - pass. You are affected if another site posts the browser to you: OAuth/OIDC response_mode=form_post callbacks (Sign in with Apple, Entra ID), SAML ACS endpoints, 3-D Secure returns, or a second front-end on another origin:

gio.toml
[security.csrf]
exempt = ["/auth/callback/apple", "/saml/acs", "/api/webhooks/*rest"]
trusted_origins = ["https://admin.example.com"]   # your other origins (also for WebSockets)

Behind nginx, keep proxy_set_header Host $host (or list the proxy in [server] trusted_proxies so X-Forwarded-Host counts): the check compares Origin with the host. See Security: CSRF protection.

7. Check pages embedded in frames

What changed: every response carries X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN and Referrer-Policy: strict-origin-when-cross-origin (plus HSTS with [server.tls]), and X-Powered-By is removed. If other sites embed your pages in an <iframe>, lift the frame header for those paths:

gio.toml
[[headers]]
path = "/embed/*rest"
[headers.headers]
x-frame-options = ""          # "" removes the default for these paths

8. Send a JSON content type

What changed: in route handlers, req.json() only parses bodies sent as application/json or application/*+json; anything else throws UnsupportedMediaTypeError, a 415 unless caught. Make your fetch() calls say what they send:

diff
  await fetch('/api/posts', {
    method: 'POST',
+   headers: { 'content-type': 'application/json' },
    body: JSON.stringify(post),
  });

A body that is empty, not UTF-8 or not valid JSON now throws MalformedBodyError - a 400 unless caught - instead of the parser's SyntaxError or a plain Error (a 500). A catch that tests err instanceof SyntaxError silently stops matching; test isMalformedBodyError(err) instead:

app/api/posts/route.ts
- import type { GioRequest } from '@gio.js/core';
+ import { isMalformedBodyError, type GioRequest } from '@gio.js/core';

  export function POST(req: GioRequest) {
    let post: unknown;
    try {
      post = req.json();
    } catch (err) {
-     if (err instanceof SyntaxError) return Response.json({ error: 'Invalid JSON' }, { status: 400 });
+     if (isMalformedBodyError(err)) return Response.json({ error: 'Invalid JSON' }, { status: 400 });
      throw err;
    }
    return { created: post };
  }

req.body still holds the raw body for other formats, and req.formData() parses forms. See Request errors.

9. Check cached pages that read cookies

What changed: a page that exports revalidate but whose getServerSideProps reads ctx.cookies, the cookie or authorization header, ctx.ip, ctx.host or ctx.scheme now renders per request and is never stored - beta.7 cached it and served the first visitor's page to everyone. Neither is a response that sets a cookie. A warning in the server log names each such route. Either drop revalidate, or cache the shared part and personalize inside Suspense holes:

app/shop/page.tsx
  export const revalidate = 60;
+ export const shell = 'cache';   // cache everything above the first pending <Suspense>

Cached pages also send Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=... now, so a CDN in front caches them - and an on-demand purge does not reach the CDN. Set your own Cache-Control with a [[headers]] rule where that is not wanted. See Caching.

10. Update error.tsx

What changed: in production a failed render shows only a digest - a short reference the real error is logged under - and error.tsx receives { error: { message, digest }, reset }, where message is Internal Server Error. error.tsx is also a client error boundary now, bundled into every page below its folder, so it must not import server-only code:

app/error.tsx
- interface ErrorPageProps { error?: { message: string } }
+ import type { ErrorPageProps } from '@gio.js/core';

- export default function Error({ error }: ErrorPageProps) {
+ export default function Error({ error, reset }: ErrorPageProps) {
    return (
      <div>
        <h1>Something went wrong</h1>
+       {error.digest !== undefined && <p>Error reference: <code>{error.digest}</code></p>}
+       {reset !== undefined && <button onClick={reset}>Try again</button>}
      </div>
    );
  }

Search your logs for the digest to find the message and stack. See Error Handling.

11. WebSockets

If you use wsHandler or useWebSocket:

  • useWebSocket reconnects by default, with backoff. Pass reconnect: false for the old behavior.
  • A path with no wsHandler closes with 4404.
  • An async wsHandler now decides the connection: the socket gets broadcasts only once it resolves (to anything but false), and a rejected promise closes it with 1011. A handler that awaits for the socket's whole lifetime must return once its listeners are set up:
    app/chat/route.ts
      import { broadcast, type GioSocket } from '@gio.js/core';
    
      export async function wsHandler(socket: GioSocket) {
        socket.join('lobby');
        socket.on('message', (data) => broadcast('lobby', String(data)));
    -   await new Promise((resolve) => socket.on('close', resolve));   // pending while the socket is open
      }

See WebSockets.

12. Plugins and header rules that touch cookies

Cookies a page or route handler sets now reach an onResponse plugin in res.setCookies, not res.headers['set-cookie']; set setCookies: [] to strip them. A set-cookie header rule adds its cookie next to the response's own instead of replacing them. Header rules also apply to redirect and guard responses, and match the requested path rather than a rewritten one.

13. Typed routes

.gio/routes.d.ts now fills a global registry that href(), useParams() and the @gio.js/core types all read. Routes you added by hand to @gio.js/react's GioRegisteredRoutes still type href(); move them so the core types see them too:

types/routes.d.ts
- declare module '@gio.js/react' {
-   interface GioRegisteredRoutes {
-     '/legacy/:id': { id: string };
-   }
- }
+ declare global {
+   namespace GioJS {
+     interface RegisteredRoutes {
+       '/legacy/:id': { id: string };
+     }
+   }
+ }
+ export {};

In CI, run gio typegen before tsc so the file exists without starting a server.

14. Development from another machine

What changed: the dev endpoints (error-overlay codeframes, open-in-editor, the dashboard) and dev error details answer only local hosts on a connection from the same machine. If you open the dev server from a VM, a container port mapping, a phone on your network or a tunnel, list that host:

gio.toml
[dev]
allowed_hosts = ["myvm.local", "192.168.1.20", "*.tunnel.example"]

An entry of "*", which beta.7 dropped as invalid, now opens the dev endpoints and error details to every host and every machine, with a startup warning. Open-in-editor still takes same-origin requests only.

15. Deploys

  • Keep-alive. Idle HTTP/1.1 keep-alive connections now close after 10 seconds ([server] header_read_timeout_secs). A proxy that pools upstream connections longer - nginx keepalive, ingress-nginx and AWS ALB default to 60 seconds - can answer with occasional 502s. Keep the proxy's upstream idle timeout below 10 seconds, or raise header_read_timeout_secs and idle_timeout_secs above it.
  • Metrics. A [metrics] section with neither token nor ip_allowlist answers only this machine now. Give your scraper a token or an allowlist:
    gio.toml
    [metrics]
    ip_allowlist = ["10.0.0.0/8"]          # your Prometheus network
    # ip_allowlist = ["0.0.0.0/0", "::/0"] # everyone, as before (startup warns)
    Behind a proxy on the same machine, list it in [server] trusted_proxies, or every client looks local.
  • Static export. gio export now ships client bundles and each page's getServerSideProps props as JSON: never return secrets from it. public/ is copied to the root of out/.
  • create-giojs rejects unknown flags and needs --force for a non-empty directory; it runs git init unless you pass --no-git.

16. Verify

bash
npx gio doctor --prod        # configuration, versions, secrets, port
npx gio routes               # the URLs your app answers
npm start                    # production mode; read the startup warnings
curl -sI http://localhost:3000/ | grep -i 'x-frame-options\|cache-control\|x-gio-cache'

Every startup warning names a gio.toml key that loosens a protection; each one should be deliberate. Then follow the Production Checklist.