create-vitnode-app 2.0.0-canary.1 → 2.0.0-canary.2

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/README.md CHANGED
@@ -59,8 +59,14 @@ pnpm create vitnode-app@latest --plugin
59
59
  npm create vitnode-app@latest -- --plugin
60
60
  ```
61
61
 
62
- The generator creates the package and adds its workspace dependency. Register it
63
- in the host’s `vitnode.config.ts` to enable the feature.
62
+ The generator creates the package and adds its workspace dependency. Enable the
63
+ feature by registering it in the host’s `vitnode.config.ts`:
64
+
65
+ ```ts
66
+ import { myPlugin } from '@acme/my-plugin/config'
67
+
68
+ plugins: [myPlugin()]
69
+ ```
64
70
 
65
71
  ## Options
66
72
 
@@ -38,3 +38,25 @@ To start the development server, run the following command:
38
38
  ```bash
39
39
  pnpm dev
40
40
  ```
41
+
42
+ ## Configuration
43
+
44
+ Two files, and the line between them is one question: may a browser hold this?
45
+
46
+ | File | Holds |
47
+ | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------- |
48
+ | `src/vitnode.config.ts` | locales, metadata, theme, `debug`, enabled plugin ids - plain data, read by the browser, the server, and your Vite build |
49
+ | `src/vitnode.server.config.ts` | message loaders - server only |
50
+
51
+ Add a language to `i18n.locales` in the shared config; register the files that
52
+ translate it in `src/locales/packages.ts` (a package's own translations) or
53
+ `src/locales/app.ts` (your rewordings). `pnpm vitnode i18n:create de Deutsch`
54
+ does all three.
55
+
56
+ `src/start.ts` is one call to `createVitNodeStart`, which installs CSRF
57
+ protection for server functions, canonical locale redirects with the
58
+ remembered-locale cookie, and the `private, no-store` directive every rendered
59
+ document needs. Add your own request middleware with `requestMiddleware` - it
60
+ runs after all of it.
61
+
62
+ See [Configuration](https://vitnode.com/docs/dev/configuration).
@@ -3,8 +3,6 @@ import { config } from "dotenv";
3
3
  import { coreRelations } from "@vitnode/core/database/relations";
4
4
  import { drizzle } from "drizzle-orm/postgres-js";
5
5
 
6
- import { i18n } from "./i18n.js";
7
-
8
6
  config({
9
7
  quiet: true,
10
8
  });
@@ -15,14 +13,33 @@ export const POSTGRES_URL =
15
13
  export const vitNodeApiConfig = buildApiConfig({
16
14
  plugins: [],
17
15
  /**
18
- * The installation's languages - see `src/i18n.ts`, and keep it in step with
19
- * the web app's file of the same name.
16
+ * The languages this installation serves.
17
+ *
18
+ * The API half of a split deployment. `apps/web/src/vitnode.config.ts`
19
+ * declares the same list, and the two are one declaration in two places by
20
+ * necessity rather than by design: they are separate packages, so neither can
21
+ * import the other's. Nothing walks the filesystem looking for the web app's
22
+ * config either - a bootstrap that guessed at a sibling application is exactly
23
+ * what that replaced, and it guessed wrong the moment the two were not laid
24
+ * out the way it expected.
20
25
  *
21
- * This app owns the schema, so `vitnode db:prepare` seeds `core_languages`
22
- * from this list. Leave it out and the seed falls back to `en` alone, whatever
23
- * the site serves.
26
+ * They have to agree, and this is the copy that matters most: this app owns
27
+ * the schema, so `vitnode db:prepare` seeds `core_languages` from *this* list.
28
+ * A language that is here and not in the web app's renders nowhere; one that
29
+ * is in the web app's and not here has no row in the database.
30
+ *
31
+ * Packages ship their own translations, so a new locale needs no `messages`
32
+ * entry - anything untranslated falls back to `defaultLocale` key by key.
24
33
  */
25
- i18n,
34
+ i18n: {
35
+ defaultLocale: "en",
36
+ locales: [{ code: "en", name: "English" }],
37
+ /**
38
+ * Explicit, because this API renders emails on a server: without one, dates
39
+ * format in whatever zone the host happens to run in.
40
+ */
41
+ timeZone: "UTC",
42
+ },
26
43
  dbProvider: drizzle({
27
44
  connection: POSTGRES_URL,
28
45
  relations: coreRelations,
@@ -2,7 +2,7 @@ import { buildApiConfig } from "@vitnode/core/vitnode.config";
2
2
  import { coreRelations } from "@vitnode/core/database/relations";
3
3
  import { drizzle } from "drizzle-orm/postgres-js";
4
4
 
5
- import { i18n } from "./i18n";
5
+ import { vitNodeConfig } from "./vitnode.config";
6
6
 
7
7
  export const POSTGRES_URL =
8
8
  process.env.POSTGRES_URL ?? "postgresql://root:root@localhost:5432/vitnode";
@@ -14,16 +14,15 @@ export const vitNodeApiConfig = buildApiConfig({
14
14
  },
15
15
  plugins: [],
16
16
  /**
17
- * The same `src/i18n.ts` the frontend config reads, because this app is both:
18
- * one declaration of which languages exist, spread into `buildConfig` through
19
- * `vitnode.shell.config.ts` and passed here.
17
+ * The site's own locale declaration, because this app is both: `vitnode.config.ts`
18
+ * is the one statement of which languages exist, and both configs read it.
20
19
  *
21
20
  * It is also what `vitnode db:prepare` seeds `core_languages` from - this app
22
- * owns the schema - so adding a language here and re-running `dev` inserts its
23
- * row. Leave it out and the seed falls back to `en` alone, whatever the site
24
- * serves.
21
+ * owns the schema - so adding a language there and re-running `dev` inserts
22
+ * its row. Leave it out and the seed falls back to `en` alone, whatever the
23
+ * site serves.
25
24
  */
26
- i18n,
25
+ i18n: vitNodeConfig.i18n,
27
26
  dbProvider: drizzle({
28
27
  connection: POSTGRES_URL,
29
28
  relations: coreRelations,
@@ -17,13 +17,13 @@ import { pluginAdminNav } from "#/admin-nav.gen";
17
17
  *
18
18
  * ## Why it is not read from `vitnode.config.ts`
19
19
  *
20
- * That config is server-side on purpose (`vitnode.shell.config.ts` explains the
21
- * split): it carries message loaders and API wiring, which a browser bundle has
22
- * no business holding. A sidebar needs the ids, the hrefs, the permissions, the
23
- * icons and the content type definitions, all of which are plain data. The
24
- * generated projection is exactly that half, so the browser gets the navigation
25
- * without the Content Engine's UI - which arrives, when a content screen
26
- * actually renders, through `src/lib/content-registry.ts` instead.
20
+ * The config does carry each plugin's registration, and a Next.js host walks it
21
+ * in its render pass. Reading it here would make every configured plugin's
22
+ * editing screens reachable from the module the document shell imports, which is
23
+ * the one graph that is never lazy. The generated projection carries exactly
24
+ * what a sidebar needs instead - ids, hrefs, permissions, icons and content type
25
+ * definitions, all plain data - and the Content Engine's UI arrives separately,
26
+ * when a content screen actually renders, through `src/lib/content-registry.ts`.
27
27
  *
28
28
  * ## Module scope, and why that matters twice
29
29
  *
@@ -20,12 +20,13 @@ import { pluginContentTypes } from "#/content-registry.gen";
20
20
  *
21
21
  * ## Why it is not read from `vitnode.config.ts`
22
22
  *
23
- * That config is server-side on purpose (`vitnode.shell.config.ts` explains the
24
- * split): it carries message loaders and API wiring, which a browser bundle has
25
- * no business holding. The generated projection is the browser-safe half - the
26
- * definitions, the icons and the override components - so the AdminCP gets the
27
- * content screens without the server config. The same arrangement
28
- * `src/lib/admin-nav.ts` uses for the sidebar, one layer deeper.
23
+ * The config does carry each plugin's registration, and a Next.js host reads it
24
+ * from there. What it cannot give is *when*: it is imported by the document
25
+ * shell, so anything reachable from it is reachable eagerly. This module is
26
+ * loaded behind a dynamic `import()` in `src/router.tsx` instead, so the
27
+ * definitions, the icons and the override components arrive with the content
28
+ * route - the same arrangement `src/lib/admin-nav.ts` uses for the sidebar, one
29
+ * layer deeper.
29
30
  *
30
31
  * ## Registration, and where it belongs in the import graph
31
32
  *
@@ -2,8 +2,8 @@ import { createServerFn } from "@tanstack/react-start";
2
2
  import { configureIntl, validateIntlInput } from "@vitnode/core/tanstack/i18n";
3
3
  import { IntlProvider } from "use-intl";
4
4
 
5
- import { i18n } from "#/i18n";
6
5
  import { loadIntlMessages } from "#/server/messages.server";
6
+ import { vitNodeConfig } from "#/vitnode.config";
7
7
 
8
8
  /**
9
9
  * One language's messages for one set of namespaces, fetched on the server.
@@ -29,9 +29,9 @@ export const getIntlMessages = createServerFn()
29
29
  * Everything in `@vitnode/core/tanstack/i18n` reads what this registers, so a
30
30
  * route file imports `RouteMessages` and `intlQueryOptions` straight from the
31
31
  * package. What must not happen is a route running before this module has been
32
- * evaluated - so the two framework entry points, `src/router.tsx` and
33
- * `src/start.ts`, both import from here, and `src/tests/intl-runtime.test.ts`
34
- * fails if either stops doing so.
32
+ * evaluated - so `src/router.tsx`, which owns the route tree, imports from
33
+ * here. The request pipeline needs no such guarantee: `createVitNodeStart`
34
+ * derives its own locale routing from the config it is handed.
35
35
  *
36
36
  * The registration is at module scope but reads `getIntlMessages` above only by
37
37
  * reference, so the order within this file does not matter: the validator and
@@ -61,7 +61,7 @@ export const {
61
61
  * back to client rendering, and the page still appears.
62
62
  */
63
63
  hostIntlProvider: IntlProvider,
64
- i18n,
64
+ i18n: vitNodeConfig.i18n,
65
65
  });
66
66
 
67
67
  /**
@@ -1,17 +1,17 @@
1
- import type { i18n } from "#/i18n";
1
+ import type { vitNodeConfig } from "#/vitnode.config";
2
2
 
3
3
  import { localeRouting } from "#/lib/i18n/runtime";
4
4
 
5
5
  /**
6
- * A language this app serves, as a type. `"en" | "pl"`, derived from the config
7
- * rather than written twice.
6
+ * A language this app serves, as a type. `"en"`, or `"en" | "de"` once a second
7
+ * one is declared - derived from the config rather than written twice.
8
8
  *
9
9
  * The one i18n thing this app still owns, and it has to: `@vitnode/core` is
10
10
  * installed by apps with different language lists, so it types a locale as
11
11
  * `string` and takes this union as a type argument where the value originates
12
12
  * (`useLocale<Locale>()`, `resolveLocale<Locale>()`).
13
13
  */
14
- export type Locale = (typeof i18n.locales)[number]["code"];
14
+ export type Locale = (typeof vitNodeConfig.i18n.locales)[number]["code"];
15
15
 
16
16
  export { defaultLocale, localeRouting } from "#/lib/i18n/runtime";
17
17
 
@@ -1,6 +1,6 @@
1
1
  import { createRouteHead } from "@vitnode/core/tanstack/metadata";
2
2
 
3
- import { vitNodeShellConfig } from "#/vitnode.shell.config";
3
+ import { vitNodeConfig } from "#/vitnode.config";
4
4
 
5
5
  /**
6
6
  * A route's `head`, bound to this app's name.
@@ -16,4 +16,4 @@ import { vitNodeShellConfig } from "#/vitnode.shell.config";
16
16
  *
17
17
  * head: ({ loaderData }) => pageHead({ robots: 'index, follow', ...loaderData })
18
18
  */
19
- export const pageHead = createRouteHead(vitNodeShellConfig.metadata);
19
+ export const pageHead = createRouteHead(vitNodeConfig.metadata);
@@ -21,8 +21,8 @@ import type { AppMessagesMap } from "@vitnode/core/lib/i18n/types";
21
21
  * everything it leaves out falls back to the package's, and then to the default
22
22
  * locale, key by key.
23
23
  *
24
- * Server-side only, and kept out of `src/i18n.ts` on purpose: these are
25
- * functions, and `src/i18n.ts` is spread into the shell config, which crosses to
26
- * the browser and has to stay serializable.
24
+ * Server-side only, and kept out of `src/vitnode.config.ts` on purpose: these
25
+ * are functions, and the shared config crosses to the browser and has to stay
26
+ * serializable. `src/vitnode.server.config.ts` is what registers this map.
27
27
  */
28
28
  export const appMessages: AppMessagesMap = {};
@@ -33,8 +33,8 @@ import { CONFIG_PLUGIN as CORE } from "@vitnode/core/config";
33
33
  * en: async () => await import('@acme/blog/locales/en.json'),
34
34
  * },
35
35
  *
36
- * This is the app's *only* copy of that list - `vitnode.config.ts` and
37
- * `server/messages.server.ts` both read it from here.
36
+ * This is the app's only copy of that list - `vitnode.server.config.ts` reads it
37
+ * from here.
38
38
  */
39
39
  export const packageMessages: Record<string, LocaleMessagesMap> = {
40
40
  [CORE.pluginId]: {
@@ -24,11 +24,11 @@ import {
24
24
 
25
25
  import type { Locale } from "#/lib/i18n/shared";
26
26
 
27
- import { vitNodeShellConfig } from "#/vitnode.shell.config";
27
+ import { vitNodeConfig } from "#/vitnode.config";
28
28
 
29
29
  import appCss from "../styles.css?url";
30
30
 
31
- const { debug, i18n, metadata, theme } = vitNodeShellConfig;
31
+ const { debug, i18n, metadata, theme } = vitNodeConfig;
32
32
 
33
33
  /**
34
34
  * What the router itself provides, before any route has run.
@@ -41,13 +41,12 @@ import {
41
41
  *
42
42
  * ## `pluginWidgets` is not passed
43
43
  *
44
- * A plugin's dashboard widgets reach the Next.js board through
45
- * `getVitNodeConfig()`, which is server-side config kept out of this app's
46
- * browser bundle by `vitnode.shell.config.ts` - and this app registers its
47
- * plugins by id and messages only (see `src/vitnode.config.ts`). So the board
48
- * shows core's own widgets, which is the complete set for this install. It is
49
- * the same seam `AdminShell` leaves open for nav `declarations`, and it changes
50
- * here when plugin AdminCP registration moves over.
44
+ * A plugin declares widgets in its `admin.dashboard.widgets`, which reaches a
45
+ * Next.js board through `getVitNodeConfig()`. This route does not read the
46
+ * config - the AdminCP takes its plugin data from the generated projections, and
47
+ * neither of those carries widgets - so the board shows core's own, which is the
48
+ * complete set for this install because no configured plugin declares any. It is
49
+ * the same seam `AdminShell` leaves open for nav `declarations`.
51
50
  */
52
51
  export const Route = createFileRoute("/_admin/admin/core/")({
53
52
  loader: async ({ context }) => await loadAdminDashboardRoute(context),
@@ -1,8 +1,7 @@
1
1
  import "@tanstack/react-start/server-only";
2
2
  import { createIntlMessagesLoader } from "@vitnode/core/tanstack/i18n/server";
3
3
 
4
- import { packageMessages } from "#/locales/packages";
5
- import { vitNodeConfig } from "#/vitnode.config";
4
+ import { vitNodeServerConfig } from "#/vitnode.server.config";
6
5
 
7
6
  export type { IntlMessages } from "@vitnode/core/tanstack/i18n/server";
8
7
 
@@ -15,15 +14,8 @@ export type { IntlMessages } from "@vitnode/core/tanstack/i18n/server";
15
14
  * place, the per-key fallback to the default locale, and the namespace pick that
16
15
  * keeps every plugin's AdminCP copy out of a page that renders none of it.
17
16
  *
18
- * What is this app's, and can only be: the plugins it registered, the languages
19
- * it declares, and `packageMessages` - the static loaders that stand in for each
20
- * package's own locale barrel, because a barrel's
21
- * `import("./en.json", { with: { type: "json" } })` is a specifier Rollup will
22
- * not follow. See `src/locales/packages.ts` for the reproduction.
17
+ * What is this app's is the server config it is handed - the plugins it
18
+ * registered, the languages it declares, and the loaders that stand in for each
19
+ * package's own locale barrel.
23
20
  */
24
- export const loadIntlMessages = createIntlMessagesLoader({
25
- appMessages: vitNodeConfig.i18n.messages,
26
- defaultLocale: vitNodeConfig.i18n.defaultLocale,
27
- packageMessages,
28
- plugins: vitNodeConfig.plugins,
29
- });
21
+ export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig);
@@ -1,88 +1,24 @@
1
- import {
2
- createCsrfMiddleware,
3
- createMiddleware,
4
- createStart,
5
- } from "@tanstack/react-start";
6
- import { handleLocaleRequest } from "@vitnode/core/tanstack/i18n/server";
1
+ import { createVitNodeStart } from "@vitnode/core/tanstack/start";
7
2
 
8
- import {
9
- applyDocumentCacheControl,
10
- applyRedirectCacheControl,
11
- } from "#/lib/document-headers";
12
- import { localeRouting } from "#/lib/i18n/runtime";
3
+ import { vitNodeConfig } from "#/vitnode.config";
13
4
 
14
5
  /**
15
- * Locale routing, as the first thing that happens to a request.
16
- *
17
- * A global request middleware rather than a wrapper around the server entry:
18
- * Start runs these before route matching and before SSR, which is exactly where
19
- * a canonical redirect belongs - the alternative is rendering a page and then
20
- * throwing it away.
21
- *
22
- * `handlerType` narrows it to page requests. Server function calls arrive on
23
- * `/_serverFn/*` with `handlerType: "serverFn"`, and redirecting an RPC to a
24
- * canonical URL would break it rather than tidy it.
25
- *
26
- * `/api/*` reaches here too and is deliberately ignored by
27
- * `handleLocaleRequest`, so the Stage 1 Hono bridge sees the request exactly as
28
- * the client sent it.
29
- *
30
- * `localeRouting` is handed in rather than read from the package's registered
31
- * runtime: Start runs request middleware before route matching, so this is the
32
- * one caller that cannot assume the router entry has been evaluated. Importing
33
- * it from `#/lib/i18n/runtime` is also what guarantees this app's i18n is
34
- * configured before the first request touches it.
6
+ * This app's Start instance.
35
7
  *
36
- * ## And what a document is allowed to say about itself
8
+ * The whole request pipeline is VitNode's, and it is mandatory rather than a
9
+ * default: CSRF protection on server functions, canonical locale redirects with
10
+ * the remembered-locale cookie, and the `private, no-store` directive every
11
+ * document carrying a dehydrated session needs.
37
12
  *
38
- * The cache directive rides along here for one reason: this is already the only
39
- * place in the application that holds every page response, before and after
40
- * rendering, and a second middleware would be a second thing to remember. Every
41
- * document this app produces carries a dehydrated Query cache containing the
42
- * visitor's own session - and, under `/admin`, an administrator's whole
43
- * permission set - so none of them may be stored by a shared cache. See
44
- * `#/lib/document-headers`, which owns the rule and says why; this file only
45
- * applies it.
46
- */
47
- const localeMiddleware = createMiddleware().server(
48
- async ({ handlerType, next, request }) => {
49
- if (handlerType !== "router") return await next();
50
-
51
- const { redirect, setCookie } = handleLocaleRequest(request, localeRouting);
52
- if (redirect) {
53
- applyRedirectCacheControl(redirect);
54
-
55
- return redirect;
56
- }
57
-
58
- const result = await next();
59
-
60
- // `append`, not `set`: the API mounted at `/api/*` and the auth flow both
61
- // mint their own cookies, and overwriting the header would sign people out.
62
- if (setCookie) result.response.headers.append("set-cookie", setCookie);
63
-
64
- // After the cookie, so a document that just wrote one is covered by the
65
- // same directive as one that did not. Only an HTML response is touched -
66
- // `/api/*` reaches here too, and the API's own caching is not this
67
- // middleware's to decide.
68
- applyDocumentCacheControl(result.response);
69
-
70
- return result;
71
- },
72
- );
73
-
74
- /**
75
- * This app's Start instance.
13
+ * Add this app's own request middleware - a request id, a tracing span, a
14
+ * maintenance gate - with `requestMiddleware`. It runs after all of the above,
15
+ * which is the only safe place for it: a redirect ends the request, so anything
16
+ * in front of the locale rule would run twice for every visitor arriving at a
17
+ * non-canonical URL.
76
18
  *
77
- * `createCsrfMiddleware` is not optional here. Start installs it *only* while an
78
- * app declares no `requestMiddleware` of its own - the moment this file exists,
79
- * the default is replaced by whatever it lists, and leaving CSRF out would
80
- * expose every server function as an unauthenticated cross-site endpoint. It is
81
- * declared first so it runs before anything else.
19
+ * export const startInstance = createVitNodeStart({
20
+ * config: vitNodeConfig,
21
+ * requestMiddleware: [myMiddleware],
22
+ * })
82
23
  */
83
- export const startInstance = createStart(() => ({
84
- requestMiddleware: [
85
- createCsrfMiddleware({ filter: ctx => ctx.handlerType === "serverFn" }),
86
- localeMiddleware,
87
- ],
88
- }));
24
+ export const startInstance = createVitNodeStart({ config: vitNodeConfig });
@@ -1,37 +1,11 @@
1
- @import 'tailwindcss';
2
- @import 'shadcn/tailwind.css';
3
- @import 'tw-animate-css';
1
+ @import "tailwindcss";
2
+ @import "shadcn/tailwind.css";
3
+ @import "tw-animate-css";
4
4
 
5
-
6
- /*
7
- * VitNode's components ship compiled, so Tailwind cannot find their classes by
8
- * scanning this app's source. Pointing it at core's build output is what makes
9
- * the toaster, the tooltip and everything else that follows look like VitNode
10
- * instead of unstyled markup.
11
- *
12
- * `dist/src/tanstack` is the line that is easiest to leave off and hardest to
13
- * diagnose. `@vitnode/core/tanstack/*` is markup as much as it is orchestration
14
- * - the header bar, the language switcher, the debug ribbon, the not-found
15
- * screen - and a class written *there* exists nowhere else, because this app
16
- * inherits those components rather than writing their markup. Without it a class
17
- * like `.w-34` is never generated, an `<svg>` with a viewBox and no width
18
- * collapses, and the header renders with no logo at all.
19
- *
20
- * Add a line per plugin that ships compiled components of its own.
21
- *
22
- * The paths are relative to this file. In a monorepo where the package manager
23
- * hoists to the repository root, that is `../../../node_modules/...` - the
24
- * generator rewrites them when it knows.
25
- */
26
5
  @source "../node_modules/@vitnode/core/dist/src/components";
27
6
  @source "../node_modules/@vitnode/core/dist/src/tanstack";
28
7
  @source "../node_modules/@vitnode/core/dist/src/views";
29
8
 
30
- /*
31
- * The design tokens every VitNode component reads. `.dark` is what
32
- * `ThemeScript` and `ThemeProvider` put on `<html>`, so the whole palette
33
- * switches from that one class.
34
- */
35
9
  :root:not(.dark) {
36
10
  --background: oklch(0.97 0 0);
37
11
  --foreground: oklch(0.145 0 0);
@@ -149,12 +123,6 @@
149
123
  --radius-4xl: calc(var(--radius) * 2.6);
150
124
  }
151
125
 
152
- /*
153
- * Last, so that a stylesheet a package brings in cannot outrank it in this
154
- * layer: `@layer base` rules are ordered by where they appear, and a library
155
- * that sets its own `body { background-color: ... }` would otherwise repaint
156
- * whichever pages happen to load it.
157
- */
158
126
  @layer base {
159
127
  * {
160
128
  @apply border-border outline-ring/50;
@@ -1,58 +1,73 @@
1
1
  import { buildConfig } from "@vitnode/core/vitnode.config";
2
2
 
3
- import { appMessages } from "./locales/app";
4
- import { vitNodeShellConfig } from "./vitnode.shell.config";
5
-
6
3
  /**
7
- * This app's frontend config, in the shape every VitNode app builds it.
4
+ * This app's configuration - the one file every VitNode app edits first.
5
+ *
6
+ * **Browser-safe, and everything here has to stay that way.** Three very
7
+ * different readers depend on it: `routes/__root.tsx` renders the document shell
8
+ * from `metadata`, `theme` and `debug`; `lib/i18n/runtime.ts` derives the locale
9
+ * routing from `i18n`; and Vite's own plugin registry loads this file with
10
+ * `jiti` while it is still resolving its config, to find out which plugins to
11
+ * generate route, navigation and content-registry imports for. So it is plain
12
+ * data and plugin *identity* - never a `() => import(...)` message loader, never
13
+ * a module that reaches a database. Anything like that goes in
14
+ * `vitnode.server.config.ts`.
15
+ *
16
+ * ## Adding a language
8
17
  *
9
- * `plugins` is empty, and a plugin is added here by id and translations rather
10
- * than through its own `blogPlugin()` entry:
18
+ * Add an entry to `locales`. Packages ship their own translations, so a new
19
+ * locale needs nothing else: anything a package has not translated falls back to
20
+ * `defaultLocale` key by key. Register the package's file for that language in
21
+ * `src/locales/packages.ts`, and put your own rewording in
22
+ * `src/locales/app.ts` - both of which `vitnode.server.config.ts` picks up.
11
23
  *
12
- * import { buildPlugin } from '@vitnode/core/lib/plugin'
13
- * import { CONFIG_PLUGIN as BLOG } from '@acme/blog/const'
24
+ * `vitnode i18n:create de Deutsch` does all of it for you.
14
25
  *
15
- * import { packageMessages } from './locales/packages'
26
+ * ## Adding a plugin
16
27
  *
17
- * plugins: [
18
- * buildPlugin({
19
- * messages: packageMessages[BLOG.pluginId],
20
- * pluginId: BLOG.pluginId,
21
- * }),
22
- * ]
28
+ * With the plugin's own factory:
23
29
  *
24
- * That is a *scope* decision rather than a compatibility one. This object's one
25
- * reader takes `pluginId` and `messages` off each plugin and nothing else, so a
26
- * full registration would add every content type's editing screen to a
27
- * server-only graph where nothing would ever look at them. The AdminCP gets its
28
- * content types from `src/content-registry.gen.ts` instead - one literal import
29
- * per configured plugin - so a browser loads them with the content route and not
30
- * before.
30
+ * import { blogPlugin } from '@acme/blog/config'
31
31
  *
32
- * The same split, one layer up, feeds the sidebar: `src/admin-nav.gen.ts` is the
33
- * navigation half - ids, hrefs, permissions, icons - and nothing that renders a
34
- * screen. See `src/lib/admin-nav.ts` and `src/lib/content-registry.ts`; both
35
- * explain why they are generated rather than read from here.
32
+ * plugins: [blogPlugin()]
33
+ *
34
+ * That is the whole registration - the factory carries the plugin's content
35
+ * types, its AdminCP navigation and its translations. Register its locale files
36
+ * in `src/locales/packages.ts` as well, which is what the message loader
37
+ * actually reads.
38
+ *
39
+ * What the AdminCP renders comes back through `src/admin-nav.gen.ts` and
40
+ * `src/content-registry.gen.ts` rather than out of this object: the build writes
41
+ * one literal import per configured plugin, and `src/router.tsx` loads the
42
+ * content registry behind a dynamic `import()`, so a content type's editing
43
+ * screen arrives with the route that renders it.
36
44
  *
37
45
  * A plugin's *pages* need nothing in this file at all. It declares them in its
38
46
  * own `src/routes.ts`, and this app's Vite build compiles them into
39
47
  * `src/plugin-routes.gen.ts`, which `src/router.tsx` mounts under the shell the
40
48
  * plugin's `area` names. No page is ever copied into `src/routes`.
41
49
  *
42
- * Server-side only, and deliberately so - see `vitnode.shell.config.ts`.
43
- * `src/server/messages.server.ts` is the only importer, and it carries the
44
- * `server-only` guard that keeps it that way.
45
- *
46
50
  * `buildConfig` also registers this object process-wide, which is how core's own
47
51
  * route files find it without being handed it as a prop.
48
52
  */
49
53
  export const vitNodeConfig = buildConfig({
50
- ...vitNodeShellConfig,
51
- /**
52
- * The shell's locale declaration, plus the message loaders that must not be in
53
- * it: `src/i18n.ts` is spread into the browser-facing shell config, and these
54
- * are functions.
55
- */
56
- i18n: { ...vitNodeShellConfig.i18n, messages: appMessages },
54
+ debug: false,
55
+ i18n: {
56
+ defaultLocale: "en",
57
+ locales: [{ code: "en", name: "English" }],
58
+ /**
59
+ * Explicit, because the app renders on a server: without one, `use-intl`
60
+ * formats dates in whatever zone the server happens to run in and warns
61
+ * that the client will disagree.
62
+ */
63
+ timeZone: "UTC",
64
+ },
65
+ metadata: {
66
+ shortTitle: "VitNode",
67
+ title: "VitNode",
68
+ },
57
69
  plugins: [],
70
+ theme: {
71
+ defaultTheme: "system",
72
+ },
58
73
  });
@@ -0,0 +1,23 @@
1
+ import "@tanstack/react-start/server-only";
2
+ import { buildServerConfig } from "@vitnode/core/vitnode.config";
3
+
4
+ import { appMessages } from "#/locales/app";
5
+ import { packageMessages } from "#/locales/packages";
6
+ import { vitNodeConfig } from "#/vitnode.config";
7
+
8
+ /**
9
+ * The half of this app's configuration a browser may never hold.
10
+ *
11
+ * Both entries are `() => import(...)` loaders that read JSON out of a package's
12
+ * build output, which is exactly what `vitnode.config.ts` cannot carry - so they
13
+ * live here, beside the shared config rather than duplicating any of it. The
14
+ * `server-only` marker makes an accidental import from a component a build error
15
+ * instead of a browser bundle with every plugin's AdminCP copy in it.
16
+ *
17
+ * `src/server/messages.server.ts` is the only reader.
18
+ */
19
+ export const vitNodeServerConfig = buildServerConfig({
20
+ config: vitNodeConfig,
21
+ messages: appMessages,
22
+ packageMessages,
23
+ });
@@ -2,11 +2,7 @@ import tailwindcss from "@tailwindcss/vite";
2
2
  import { devtools } from "@tanstack/devtools-vite";
3
3
  import { tanstackStart } from "@tanstack/react-start/plugin/vite";
4
4
  import viteReact from "@vitejs/plugin-react";
5
- import {
6
- vitNodeEnv,
7
- vitNodeOptimizeDeps,
8
- vitNodePluginRoutes,
9
- } from "@vitnode/core/framework/vite";
5
+ import { vitnode } from "@vitnode/core/framework/vite";
10
6
  import { nitro } from "nitro/vite";
11
7
  import { defineConfig } from "vite";
12
8
 
@@ -85,29 +81,20 @@ const config = defineConfig({
85
81
  },
86
82
  plugins: [
87
83
  /**
88
- * All three from `@vitnode/core/framework/vite`.
84
+ * Environment handling, the dev server's dependency pre-bundling and the
85
+ * plugin route registry, in the order they have to run.
89
86
  *
90
- * `vitNodeOptimizeDeps` takes nothing at all: it names the browser
91
- * dependencies of the package's own `dist` so `vite dev` pre-bundles them on
92
- * a cold start. Nothing crawls that `dist` before it is requested, so
93
- * without this list a screen that is opened for the first time discovers a
94
- * dependency mid-session, the optimizer re-runs, and a page holding modules
95
- * from the previous run ends up with two copies of React.
87
+ * `appRoot` is `import.meta.dirname` because a Vite config is loaded with
88
+ * the working directory set to wherever the command ran, which in a
89
+ * monorepo is regularly the repository root.
96
90
  *
97
- * `vitNodeEnv` takes no `clientEnv`: this app publishes nothing to the
98
- * browser beyond the two keys the package inlines for every VitNode install
91
+ * `clientEnv` is not passed: this app publishes nothing to the browser
92
+ * beyond the two keys VitNode inlines for every install
99
93
  * (`NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_WEB_URL`). Anything named there is
100
- * compiled into JavaScript anyone can read, so an empty list is the right
101
- * default and a key is added only when something in the browser genuinely
102
- * reads it.
103
- *
104
- * `appRoot` is `import.meta.dirname` because a Vite config is loaded with the
105
- * working directory set to wherever the command ran, which in this monorepo
106
- * is regularly the repository root.
94
+ * compiled into JavaScript anyone can read, so a key is added only when
95
+ * something in the browser genuinely reads it.
107
96
  */
108
- vitNodeEnv(),
109
- vitNodeOptimizeDeps(),
110
- vitNodePluginRoutes({ appRoot: import.meta.dirname }),
97
+ vitnode({ appRoot: import.meta.dirname }),
111
98
  devtools(),
112
99
  nitro({ rollupConfig: { external: [/^@sentry\//] } }),
113
100
  tailwindcss(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-vitnode-app",
3
- "version": "2.0.0-canary.1",
3
+ "version": "2.0.0-canary.2",
4
4
  "description": "Create a new VitNode app in seconds.",
5
5
  "author": "VitNode Team",
6
6
  "license": "MIT",
@@ -42,7 +42,7 @@
42
42
  "eslint": "^10.7.0",
43
43
  "typescript": "^6.0.3",
44
44
  "vitest": "^4.1.10",
45
- "@vitnode/config": "2.0.0-canary.1"
45
+ "@vitnode/config": "2.0.0-canary.2"
46
46
  },
47
47
  "scripts": {
48
48
  "build:scripts": "tsc -p tsconfig.build.json && node dist/src/prepare/prepare.js",
@@ -1,38 +0,0 @@
1
- import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types";
2
-
3
- /**
4
- * The languages this installation serves.
5
- *
6
- * The API half of a split deployment. `apps/web` has a file of the same name,
7
- * and the two are one declaration in two places by necessity rather than by
8
- * design: they are separate packages, so neither can import the other's.
9
- *
10
- * They have to agree, and this is the copy that matters most - this app owns the
11
- * schema, so `vitnode db:prepare` seeds `core_languages` from *this* list. A
12
- * language that is here and not in the web app's renders nowhere; one that is in
13
- * the web app's and not here has no row in the database, and everything keyed on
14
- * a language row has nowhere to put it.
15
- *
16
- * Deliberately not discovered: nothing walks the filesystem looking for the web
17
- * app's config. A bootstrap that guessed at a sibling application is exactly
18
- * what this replaced, and it guessed wrong the moment the two were not laid out
19
- * the way it expected.
20
- *
21
- * Packages ship their own translations, so a new locale needs no `messages`
22
- * entry - anything untranslated falls back to `defaultLocale` key by key.
23
- */
24
- export const i18n = {
25
- defaultLocale: "en",
26
- /**
27
- * Explicit, because this API renders emails on a server: without one, dates
28
- * format in whatever zone the host happens to run in.
29
- */
30
- timeZone: "UTC",
31
- locales: [
32
- {
33
- code: "en",
34
- name: "English",
35
- },
36
- ],
37
- messages: {},
38
- } satisfies VitNodeI18nConfig;
@@ -1,36 +0,0 @@
1
- import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types";
2
-
3
- /**
4
- * The languages this app serves.
5
- *
6
- * Its own module so the web config and the API config can point at the same
7
- * object instead of drifting apart - the site and the emails it sends have to
8
- * agree on which languages exist.
9
- *
10
- * Add a language by adding an entry. Packages ship their own translations, so a
11
- * new locale needs no `messages` here: anything a package has not translated
12
- * falls back to `defaultLocale` key by key. Register the package's own file for
13
- * that language in `src/locales/packages.ts`, and put your own rewording in
14
- * `src/locales/app.ts`.
15
- */
16
- export const i18n = {
17
- defaultLocale: "en" as const,
18
- /**
19
- * Explicit, because the app renders on a server: without one, `use-intl`
20
- * formats dates in whatever zone the server happens to run in and warns that
21
- * the client will disagree. Stage 3, which owns the locale runtime, is where a
22
- * per-visitor zone would come from.
23
- */
24
- timeZone: "UTC",
25
- /**
26
- * `as const` on each code, and nothing else: it keeps `"en" | "pl"` out of
27
- * the widening `satisfies` would otherwise do, which is what makes `Locale`
28
- * in `lib/i18n/shared.ts` a real union rather than an alias for `string`.
29
- */
30
- locales: [
31
- {
32
- code: "en" as const,
33
- name: "English",
34
- },
35
- ],
36
- } satisfies VitNodeI18nConfig;
@@ -1,128 +0,0 @@
1
- /**
2
- * What a VitNode document response may say about being stored.
3
- *
4
- * `private, no-store`, and it is not a precaution - it is a description of what
5
- * is in the body. Every page this application renders streams a dehydrated Query
6
- * cache into its HTML, and that cache always holds `["vitnode","session"]`: the
7
- * visitor's own name, avatar and `isAdmin` flag. Inside `/admin` it also holds
8
- * `["vitnode","admin-session"]`, which is that administrator's entire permission
9
- * set. `tanstack/auth/session-query` and `tanstack/admin/session-query` both say
10
- * so in their own words - "that document is personalised and must not be served
11
- * from a shared cache" - and until now nothing on the response said it back.
12
- *
13
- * Nothing caches these documents today. That is a property of the current
14
- * deployment, not of the application: the moment a CDN, a reverse proxy or a
15
- * `Cache-Control`-respecting edge sits in front of the Node server, an absent
16
- * directive is an invitation to store one visitor's HTML and serve it to the
17
- * next. Stage 15 is when that becomes likely, so the header belongs here now.
18
- *
19
- * ## It is an invariant, not a default
20
- *
21
- * This started as a fallback for documents that said nothing, and that was
22
- * wrong. A default is something a route may override, and at this architecture
23
- * level there is no override a route could correctly choose: the dehydrated
24
- * cache is written into the stream by `setupRouterSsrQueryIntegration` for
25
- * *every* document, so a route opting into `public, max-age=60` would be
26
- * publishing whichever visitor rendered first to everyone who asked next. The
27
- * route cannot know that, because the private payload is not something the route
28
- * put there.
29
- *
30
- * So the directive is forced rather than filled in, and a route that sets its
31
- * own is overwritten rather than obeyed. That is the whole difference between
32
- * this being a hardening measure and it being a security invariant.
33
- *
34
- * Public document caching is not forbidden forever - it is forbidden *while the
35
- * session is dehydrated into the document*. Introducing it later is a separate,
36
- * explicit piece of architecture in which the private state is kept out of the
37
- * shared body (a public shell fetching its session client-side, say), and the
38
- * invariant here would move with it rather than being quietly relaxed by a route
39
- * that wanted a faster page.
40
- *
41
- * `private` bars a shared cache. `no-store` bars every cache, including the
42
- * browser's own disk cache - which is the half that matters on a shared machine,
43
- * where the previous person's permission set should not be recoverable from
44
- * `chrome://cache` after they sign out. The pair is the standard spelling for
45
- * "this body belongs to exactly one person, once".
46
- *
47
- * The known cost is the back/forward cache. `no-store` used to make a page
48
- * outright ineligible for bfcache in Chromium; current versions keep such pages
49
- * eligible but evict them when cookies change - which for this app means a
50
- * sign-in or sign-out invalidates a back-navigation that would otherwise have
51
- * restored a page rendered for the previous session. That is the correct trade
52
- * and the outcome anybody would want, but it is a real difference and it is
53
- * worth a look during a manual pass rather than a surprise later.
54
- */
55
- export const DOCUMENT_CACHE_CONTROL = "private, no-store";
56
-
57
- /**
58
- * Whether this response is one of the documents the rule above describes.
59
- *
60
- * One question, and it is what keeps the API out of it. `/api/*` is served by
61
- * the Hono bridge through this same middleware, and a bare `GET` from it carries
62
- * no `Cache-Control` of its own - so a rule that applied to every response would
63
- * quietly forbid clients from caching the API. An HTML content-type is the
64
- * honest way to ask "is this a page", it needs no path list to be kept in step
65
- * with the router, and it cannot be wrong about a response that has already been
66
- * produced.
67
- *
68
- * It deliberately does *not* ask whether a directive is already present. That
69
- * used to be the second half of this predicate, and it is exactly the exemption
70
- * the invariant above cannot afford - see {@link applyDocumentCacheControl}.
71
- *
72
- * A redirect is deliberately not matched here - it has no content type - and is
73
- * handled by {@link applyRedirectCacheControl} instead, which wants a narrower
74
- * rule.
75
- */
76
- const isRenderedDocument = (headers: Headers): boolean =>
77
- (headers.get("content-type") ?? "").toLowerCase().startsWith("text/html");
78
-
79
- /**
80
- * Says what a rendered document is, on the response about to be sent.
81
- *
82
- * `set` rather than a conditional fill, and that is the fix: whatever the
83
- * response was carrying is replaced. A route cannot opt out, because a route is
84
- * not in a position to know what is in the body it is opting out for.
85
- *
86
- * Only `text/html` is touched. Everything else the middleware sees - the API,
87
- * assets, client chunks, a `204` with no content type at all - keeps whatever it
88
- * had, including nothing.
89
- *
90
- * Mutates rather than returning a new `Response`, because the middleware already
91
- * holds the one Start produced and rebuilding it would mean copying a stream.
92
- * The same reason `set-cookie` is appended in place a few lines away.
93
- */
94
- export const applyDocumentCacheControl = (response: Response): void => {
95
- if (!isRenderedDocument(response.headers)) return;
96
-
97
- response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL);
98
- };
99
-
100
- /**
101
- * The same for a locale redirect, but only when it is carrying a cookie.
102
- *
103
- * A `308` from `/en/discover` to `/discover` is a fact about URLs, identical for
104
- * every visitor, and permanently cacheable - which is most of the point of
105
- * answering with one. So it keeps that property by default.
106
- *
107
- * The exception is the redirect that also writes the locale cookie, which is
108
- * what `/pl/admin` produces: a stored copy of that would hand the next visitor
109
- * through the same shared cache a `Set-Cookie` chosen by somebody else, and
110
- * quietly switch their language. Shared caches are generally expected to refuse
111
- * a `Set-Cookie` response, but "generally expected" is not a property this
112
- * application can assert about somebody else's proxy, and one visitor's cookie
113
- * reaching another's browser is not the kind of thing to leave to convention.
114
- *
115
- * So the cookie-carrying case is forced, for the same reason the document is: a
116
- * `public` directive already on such a redirect is overwritten rather than
117
- * respected, because the thing that makes it unsafe to share is the `Set-Cookie`
118
- * beside it and not whatever the directive claims.
119
- *
120
- * The cookie-less case keeps its existing semantics untouched, directive and
121
- * all. It carries no private state, so there is nothing here to protect and
122
- * nothing to override.
123
- */
124
- export const applyRedirectCacheControl = (response: Response): void => {
125
- if (!response.headers.has("set-cookie")) return;
126
-
127
- response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL);
128
- };
@@ -1,35 +0,0 @@
1
- import type { VitNodeConfig } from "@vitnode/core/vitnode.config";
2
-
3
- import { i18n } from "./i18n";
4
-
5
- /**
6
- * The VitNode config the browser is allowed to see.
7
- *
8
- * Everything in `VitNodeConfig` except `plugins`, and that omission is the whole
9
- * point. The plugin registry carries each plugin's translations as `import()`s
10
- * of JSON inside its `dist`, and eventually its AdminCP components - neither of
11
- * which a browser bundle should hold. In Next.js the boundary is drawn for you:
12
- * `vitnode.config.ts` is only ever read by Server Components, so none of it
13
- * reaches the client. TanStack Start has no such boundary - anything the root
14
- * route imports is in the browser bundle - so the split is made here instead, by
15
- * hand.
16
- *
17
- * `vitnode.config.ts` spreads this into `buildConfig` with the plugins added, so
18
- * there is one source for the metadata, the theme and the locales rather than
19
- * two that agree until they don't.
20
- *
21
- * Everything here is plain, serializable data. That is a rule, not a
22
- * coincidence: this module is imported by the document shell, which renders on
23
- * both sides of hydration.
24
- */
25
- export const vitNodeShellConfig = {
26
- debug: false,
27
- i18n,
28
- metadata: {
29
- shortTitle: "VitNode",
30
- title: "VitNode",
31
- },
32
- theme: {
33
- defaultTheme: "system",
34
- },
35
- } satisfies Omit<VitNodeConfig, "plugins">;