@omg-dev/vite-plugin 0.4.40 → 0.4.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omg-dev/vite-plugin",
3
- "version": "0.4.40",
3
+ "version": "0.4.42",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -15,8 +15,8 @@
15
15
  "build": "vp pack src/index.ts --no-fail-on-warn"
16
16
  },
17
17
  "dependencies": {
18
- "@omg-dev/server": "0.4.40",
19
- "@omg-dev/schema": "0.4.40",
18
+ "@omg-dev/server": "0.4.42",
19
+ "@omg-dev/schema": "0.4.42",
20
20
  "ws": "^8.18.0"
21
21
  },
22
22
  "devDependencies": {
package/src/build.ts CHANGED
@@ -41,6 +41,9 @@ import { prerenderApp } from "./prerender.ts"
41
41
  const root = process.cwd()
42
42
  const vibesDir = path.join(root, ".vibes")
43
43
  const distDir = path.join(root, "dist")
44
+ // TanStack Start's server build output. Its presence after the client build is
45
+ // what puts this build in SSR mode (see step 4).
46
+ const startServerEntry = path.join(distDir, "server", "server.js")
44
47
 
45
48
  // The build is not a runtime. Step 4b (prerender) spins up a nested Vite
46
49
  // dev server to SSR the app's root view, which — via the vibes vite-plugin's
@@ -189,6 +192,9 @@ async function main() {
189
192
  fs.writeFileSync(path.join(vibesDir, "workflows.generated.ts"), wfLines.join("\n"))
190
193
 
191
194
  // ── 3. write server.entry.ts ──────────────────────────────────────────────
195
+ // Deferred until after the client build (step 4) because the entry's shape
196
+ // depends on whether that build emitted an SPA or a Start SSR handler.
197
+ const writeServerEntry = (ssr: boolean) => {
192
198
  log("3/5 generating .vibes/server.entry.ts")
193
199
  const entry = [
194
200
  "// AUTO-GENERATED by @omg-dev/vite-plugin. Do not edit.",
@@ -201,6 +207,10 @@ async function main() {
201
207
  'import { routes } from "./routes.generated.ts"',
202
208
  'import { triggers } from "./triggers.generated.ts"',
203
209
  'import { workflows } from "./workflows.generated.ts"',
210
+ // Start's server build. bun build inlines it (and react, h3, seroval, …)
211
+ // into server.mjs, which is what lets the runtime keep its no-node_modules
212
+ // contract — the raw dist/server/server.js externalizes all of them.
213
+ ...(ssr ? ['import startServer from "../dist/server/server.js"'] : []),
204
214
  "",
205
215
  "const PORT = Number(process.env.PORT) || 3000",
206
216
  "",
@@ -212,7 +222,17 @@ async function main() {
212
222
  " routes,",
213
223
  " triggers,",
214
224
  " workflows,",
215
- " staticDir: \"dist\",",
225
+ // Under SSR the browser assets live in dist/client and Start owns every
226
+ // HTML document, so serveStatic must NOT SPA-fallback — an unmatched path
227
+ // has to reach Start's fetch handler below to get a real 404 page or a
228
+ // server-rendered route.
229
+ ...(ssr
230
+ ? [
231
+ " staticDir: \"dist/client\",",
232
+ " staticFallthrough: true,",
233
+ " notFound: (req) => startServer.fetch(req),",
234
+ ]
235
+ : [" staticDir: \"dist\","]),
216
236
  " // Migrations run at runtime (idempotent — diffs sqlite_master) so",
217
237
  " // user data in .vibes/data.db persists across redeploys. The build",
218
238
  " // artifact intentionally does NOT ship a seeded DB.",
@@ -290,6 +310,8 @@ async function main() {
290
310
  " } },",
291
311
  " )",
292
312
  " }",
313
+ // Under SSR, unmatched requests reach Start via the `notFound` seam passed
314
+ // to createVibesServer above — so this stays one call in both modes.
293
315
  " return vibes.fetch(req)",
294
316
  " },",
295
317
  " websocket: {",
@@ -323,12 +345,25 @@ async function main() {
323
345
  "",
324
346
  ].join("\n")
325
347
  fs.writeFileSync(path.join(vibesDir, "server.entry.ts"), entry)
348
+ }
326
349
 
327
350
  // ── 4. vite build (client) ────────────────────────────────────────────────
328
351
  log("4/5 vite build (client)")
329
352
  fs.rmSync(distDir, { recursive: true, force: true })
330
353
  run("bunx", ["--bun", "vp", "build"])
331
- if (!fs.existsSync(path.join(distDir, "index.html"))) {
354
+
355
+ // SPA vs SSR is decided by what the build actually emitted, not by config
356
+ // parsing: TanStack Start emits dist/client/ + dist/server/server.js (a
357
+ // module whose default export is `{ fetch }`), where an SPA emits
358
+ // dist/index.html. Reading the output keeps this honest if Start changes
359
+ // its plugin API but keeps its output contract.
360
+ const ssr = fs.existsSync(startServerEntry)
361
+ if (ssr) {
362
+ if (!fs.existsSync(path.join(distDir, "client"))) {
363
+ die("SSR build produced dist/server/server.js but no dist/client/")
364
+ }
365
+ log("mode: SSR (TanStack Start) — dist/client + dist/server/server.js")
366
+ } else if (!fs.existsSync(path.join(distDir, "index.html"))) {
332
367
  die("vite build did not produce dist/index.html")
333
368
  }
334
369
 
@@ -337,8 +372,17 @@ async function main() {
337
372
  // Strictly additive and FAIL-SOFT (see prerender.ts): any failure leaves the
338
373
  // client build untouched, so it can never break a deploy. The client mounts
339
374
  // the full SPA over whatever HTML lands in #root.
340
- log("4b/5 prerender (SSG, fail-soft)")
341
- await prerenderApp({ root, distDir, vibesDir, log })
375
+ //
376
+ // Skipped under SSR: Start renders the document per request, so there is no
377
+ // #root shell to bake into and nothing to gain.
378
+ if (ssr) {
379
+ log("4b/5 prerender skipped (SSR renders every request)")
380
+ } else {
381
+ log("4b/5 prerender (SSG, fail-soft)")
382
+ await prerenderApp({ root, distDir, vibesDir, log })
383
+ }
384
+
385
+ writeServerEntry(ssr)
342
386
 
343
387
  // ── 5. bun build (server) ─────────────────────────────────────────────────
344
388
  log("5/5 bun build server")
@@ -382,7 +426,9 @@ async function main() {
382
426
  // causes data loss.
383
427
  // Workflows force a runtime VM: a static deploy has no server for the
384
428
  // engine to invoke, so workflow-only apps are NOT static.
385
- const isStatic = !hasDb && routes.length === 0 && workflows.length === 0
429
+ // An SSR app is never static: object storage has no server to render the
430
+ // document, so serving dist/ directly would hand visitors a blank page.
431
+ const isStatic = !ssr && !hasDb && routes.length === 0 && workflows.length === 0
386
432
 
387
433
  // Serialize the app's @omg-dev/billing declaration (if any) into the manifest.
388
434
  // The orchestrator reads manifest.catalog at deploy time and persists it to
@@ -401,7 +447,9 @@ async function main() {
401
447
  static: isStatic,
402
448
  functions: routes.length,
403
449
  artifacts: [
404
- { src: "dist", kind: "dir" },
450
+ // SSR ships only dist/client dist/server was inlined into server.mjs
451
+ // by the bun build below, so shipping it again is dead weight.
452
+ { src: ssr ? "dist/client" : "dist", kind: "dir" },
405
453
  { src: ".vibes/server.mjs", kind: "file" },
406
454
  ],
407
455
  entry: ".vibes/server.mjs",
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { isStaticPath, routeHtmlPath } from "./prerender.ts"
3
+
4
+ describe("isStaticPath", () => {
5
+ test("keeps routes that resolve to exactly one URL", () => {
6
+ expect(isStaticPath("/")).toBe(true)
7
+ expect(isStaticPath("/pricing")).toBe(true)
8
+ expect(isStaticPath("/docs/getting-started")).toBe(true)
9
+ })
10
+
11
+ // A param route has no single URL to bake, so baking one would pin every
12
+ // /posts/* request to whichever id happened to render at build time.
13
+ test("drops param and splat routes", () => {
14
+ expect(isStaticPath("/posts/$postId")).toBe(false)
15
+ expect(isStaticPath("/files/$")).toBe(false)
16
+ expect(isStaticPath("/docs/*")).toBe(false)
17
+ })
18
+
19
+ test("drops anything that is not an absolute path", () => {
20
+ expect(isStaticPath("pricing")).toBe(false)
21
+ expect(isStaticPath("")).toBe(false)
22
+ })
23
+ })
24
+
25
+ describe("routeHtmlPath", () => {
26
+ // Directory-index form is load-bearing: it is what serveStatic in
27
+ // @omg-dev/server and the static-deploy path in proxy/deploy.go resolve.
28
+ // "pricing.html" would 404 into the SPA fallback and silently undo the bake.
29
+ test("maps / to the shell and every other route to a directory index", () => {
30
+ expect(routeHtmlPath("/app/dist", "/")).toBe("/app/dist/index.html")
31
+ expect(routeHtmlPath("/app/dist", "/pricing")).toBe("/app/dist/pricing/index.html")
32
+ expect(routeHtmlPath("/app/dist", "/docs/intro")).toBe("/app/dist/docs/intro/index.html")
33
+ })
34
+ })
package/src/prerender.ts CHANGED
@@ -1,26 +1,47 @@
1
1
  // Build-time prerender (SSG) for user apps.
2
2
  //
3
- // Renders the app's root view (<App/> from src/App.tsx) to static HTML at build
4
- // time and bakes it into dist/index.html, so crawlers + first paint see real
5
- // content instead of an empty SPA shell. The client (src/main.tsx, createRoot)
6
- // mounts the full app over whatever HTML is in #root so behavior is identical
7
- // whether or not the prerender ran; it only changes the initial bytes.
3
+ // Renders the app to static HTML at build time so crawlers + first paint see
4
+ // real content instead of an empty SPA shell. The client (src/main.tsx,
5
+ // createRoot) mounts the full app over whatever HTML is in #root — so behavior
6
+ // is identical whether or not the prerender ran; it only changes the initial
7
+ // bytes.
8
+ //
9
+ // Two modes, picked automatically:
10
+ //
11
+ // router — src/routeTree.gen.ts exists (TanStack Router, the default
12
+ // template since the router landed). Every STATIC route is
13
+ // rendered at its own path and written to dist/<path>/index.html,
14
+ // with "/" going to dist/index.html. This is why it matters: the
15
+ // static serving layers fall back to index.html for unknown paths,
16
+ // so a home-only bake would serve homepage markup for /pricing —
17
+ // wrong first paint, wrong SEO. Parameterized routes ($id, splats)
18
+ // are skipped; they keep the SPA fallback.
19
+ // app — no route tree: render <App/> from src/App.tsx into
20
+ // dist/index.html. This is the pre-router behavior and every app
21
+ // created before the router still takes it, unchanged.
8
22
  //
9
23
  // FAIL-SOFT IS THE CONTRACT. This runs against arbitrary agent-generated code,
10
24
  // so anything can go wrong (a module-scope `window` reference, a render-time
11
25
  // throw, a missing App). Every failure path leaves dist/index.html exactly as
12
26
  // the client build produced it and returns normally — a prerender failure can
13
- // never fail a deploy. The worst case is "no prerender", never a broken page.
27
+ // never fail a deploy. In router mode that guarantee is per-route: one route
28
+ // that throws costs you that route's static HTML, nothing else. The worst case
29
+ // is "no prerender", never a broken page.
14
30
  import fs from "node:fs"
15
31
  import path from "node:path"
16
32
 
17
33
  const ROOT_MARKER = '<div id="root"></div>'
18
34
 
35
+ // Upper bound on how many routes we'll render in one build. A pathological
36
+ // route tree shouldn't be able to stall a deploy; past this we bake the ones
37
+ // we got and leave the rest on the SPA fallback.
38
+ const MAX_ROUTES = 50
39
+
19
40
  export type PrerenderResult =
20
- | { status: "baked"; bytes: number }
41
+ | { status: "baked"; bytes: number; routes?: number }
21
42
  | { status: "skipped"; reason: string }
22
43
 
23
- // Renders src/App.tsx to HTML and bakes it into dist/index.html. Never throws.
44
+ // Renders the app to static HTML under distDir. Never throws.
24
45
  export async function prerenderApp(opts: {
25
46
  root: string
26
47
  distDir: string
@@ -32,8 +53,10 @@ export async function prerenderApp(opts: {
32
53
  const htmlPath = path.join(distDir, "index.html")
33
54
 
34
55
  try {
56
+ const routeTreePath = path.join(root, "src", "routeTree.gen.ts")
57
+ const routerMode = fs.existsSync(routeTreePath)
35
58
  const appPath = path.join(root, "src", "App.tsx")
36
- if (!fs.existsSync(appPath)) {
59
+ if (!routerMode && !fs.existsSync(appPath)) {
37
60
  return done(log, { status: "skipped", reason: "src/App.tsx not found" })
38
61
  }
39
62
  if (!fs.existsSync(htmlPath)) {
@@ -45,24 +68,11 @@ export async function prerenderApp(opts: {
45
68
  }
46
69
 
47
70
  // Build-generated SSR entry — lives in .vibes/ so we never add a file to the
48
- // user's src tree, and works for apps that predate this feature. Imports the
49
- // conventional app root (default export of src/App.tsx) and renders it in the
50
- // same StrictMode wrapper the client uses (src/main.tsx), so the baked markup
51
- // matches the client's first render.
71
+ // user's src tree, and works for apps that predate this feature. Renders in
72
+ // the same StrictMode wrapper the client uses (src/main.tsx), so the baked
73
+ // markup matches the client's first render.
52
74
  const entryPath = path.join(vibesDir, "prerender.entry.tsx")
53
- fs.writeFileSync(
54
- entryPath,
55
- [
56
- "// AUTO-GENERATED by @omg-dev/vite-plugin. Do not edit.",
57
- 'import { StrictMode } from "react"',
58
- 'import { renderToString } from "react-dom/server"',
59
- 'import App from "../src/App.tsx"',
60
- "export function render() {",
61
- " return renderToString(<StrictMode><App /></StrictMode>)",
62
- "}",
63
- "",
64
- ].join("\n"),
65
- )
75
+ fs.writeFileSync(entryPath, routerMode ? routerEntry() : appEntry())
66
76
 
67
77
  // Use the app's own Vite config (react plugin, @/ alias, @omg-dev/vite-plugin)
68
78
  // so SSR module resolution matches the client build. middlewareMode = no
@@ -77,15 +87,51 @@ export async function prerenderApp(opts: {
77
87
  })
78
88
  try {
79
89
  const mod = (await vite.ssrLoadModule("/.vibes/prerender.entry.tsx")) as {
80
- render: () => string
90
+ render?: () => string
91
+ listPaths?: () => string[]
92
+ renderPath?: (p: string) => Promise<string>
93
+ }
94
+
95
+ if (!routerMode) {
96
+ const html = mod.render?.()
97
+ if (!html || !html.trim()) {
98
+ return done(log, { status: "skipped", reason: "empty render output" })
99
+ }
100
+ writeShell(htmlPath, shell, html)
101
+ return done(log, { status: "baked", bytes: html.length })
102
+ }
103
+
104
+ const all = mod.listPaths?.() ?? []
105
+ const paths = all.filter(isStaticPath).slice(0, MAX_ROUTES)
106
+ if (paths.length === 0) {
107
+ return done(log, { status: "skipped", reason: "no static routes in the route tree" })
108
+ }
109
+
110
+ let baked = 0
111
+ let bytes = 0
112
+ for (const p of paths) {
113
+ // Per-route fail-soft: a route that throws (loader hitting the network,
114
+ // a browser-only global at render time) simply doesn't get static HTML.
115
+ try {
116
+ const html = await mod.renderPath?.(p)
117
+ if (!html || !html.trim()) {
118
+ log(`prerender: – ${p} skipped (empty render output)`)
119
+ continue
120
+ }
121
+ writeShell(routeHtmlPath(distDir, p), shell, html)
122
+ baked++
123
+ bytes += html.length
124
+ } catch (err) {
125
+ log(`prerender: – ${p} skipped (${String(err).split("\n")[0]})`)
126
+ }
127
+ }
128
+ if (baked === 0) {
129
+ return done(log, { status: "skipped", reason: "every route failed to render" })
81
130
  }
82
- const html = mod.render()
83
- if (!html || !html.trim()) {
84
- return done(log, { status: "skipped", reason: "empty render output" })
131
+ if (all.length > paths.length) {
132
+ log(`prerender: ${all.length - paths.length} dynamic/overflow route(s) left on the SPA fallback`)
85
133
  }
86
- const out = shell.replace(ROOT_MARKER, `<div id="root">${html}</div>`)
87
- fs.writeFileSync(htmlPath, out)
88
- return done(log, { status: "baked", bytes: html.length })
134
+ return done(log, { status: "baked", bytes, routes: baked })
89
135
  } finally {
90
136
  await vite.close()
91
137
  fs.rmSync(entryPath, { force: true })
@@ -96,9 +142,74 @@ export async function prerenderApp(opts: {
96
142
  }
97
143
  }
98
144
 
145
+ // "/" → dist/index.html, "/pricing" → dist/pricing/index.html. Directory-index
146
+ // form (not "pricing.html") because that is what the static servers resolve —
147
+ // see serveStatic in @omg-dev/server and the static-deploy path in
148
+ // apps/infra/internal/proxy/deploy.go.
149
+ export function routeHtmlPath(distDir: string, routePath: string): string {
150
+ if (routePath === "/") return path.join(distDir, "index.html")
151
+ return path.join(distDir, routePath.replace(/^\/+/, ""), "index.html")
152
+ }
153
+
154
+ function writeShell(outPath: string, shell: string, html: string): void {
155
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
156
+ fs.writeFileSync(outPath, shell.replace(ROOT_MARKER, `<div id="root">${html}</div>`))
157
+ }
158
+
159
+ // Only routes that resolve to exactly one URL can be baked to a file. Anything
160
+ // with a param ($id), a splat, or a pathless/layout marker is left alone.
161
+ export function isStaticPath(p: string): boolean {
162
+ if (!p.startsWith("/")) return false
163
+ if (p.includes("$") || p.includes("*")) return false
164
+ return true
165
+ }
166
+
167
+ function appEntry(): string {
168
+ return [
169
+ "// AUTO-GENERATED by @omg-dev/vite-plugin. Do not edit.",
170
+ 'import { StrictMode } from "react"',
171
+ 'import { renderToString } from "react-dom/server"',
172
+ 'import App from "../src/App.tsx"',
173
+ "export function render() {",
174
+ " return renderToString(<StrictMode><App /></StrictMode>)",
175
+ "}",
176
+ "",
177
+ ].join("\n")
178
+ }
179
+
180
+ function routerEntry(): string {
181
+ return [
182
+ "// AUTO-GENERATED by @omg-dev/vite-plugin. Do not edit.",
183
+ 'import { StrictMode } from "react"',
184
+ 'import { renderToString } from "react-dom/server"',
185
+ 'import { RouterProvider, createRouter, createMemoryHistory } from "@tanstack/react-router"',
186
+ 'import { routeTree } from "../src/routeTree.gen"',
187
+ "",
188
+ "// Every URL the route tree can resolve, params included — the caller",
189
+ "// filters down to the static ones.",
190
+ "export function listPaths() {",
191
+ " const router = createRouter({ routeTree })",
192
+ " return Object.keys(router.routesByPath ?? {})",
193
+ "}",
194
+ "",
195
+ "// A fresh router per path: routers carry resolved match state, so reusing",
196
+ "// one across paths would bleed the previous route's markup into the next.",
197
+ "export async function renderPath(pathname) {",
198
+ " const router = createRouter({",
199
+ " routeTree,",
200
+ " history: createMemoryHistory({ initialEntries: [pathname] }),",
201
+ " })",
202
+ " await router.load()",
203
+ " return renderToString(<StrictMode><RouterProvider router={router} /></StrictMode>)",
204
+ "}",
205
+ "",
206
+ ].join("\n")
207
+ }
208
+
99
209
  function done(log: (m: string) => void, r: PrerenderResult): PrerenderResult {
100
210
  if (r.status === "baked") {
101
- log(`prerender: baked app root into dist/index.html (+${r.bytes}B static HTML)`)
211
+ const where = r.routes ? `${r.routes} route(s)` : "dist/index.html"
212
+ log(`prerender: ✓ baked app root into ${where} (+${r.bytes}B static HTML)`)
102
213
  } else {
103
214
  log(`prerender: SPA shell kept (no prerender) — ${r.reason}`)
104
215
  }