Skip to content

Commit ab61345

Browse files
miknclaude
andcommitted
feat: staging_srcs on ts_bundle + working Remix example
Add staging_srcs attr to ts_bundle that copies source files to a writable staging directory before running Vite. This lets framework Vite plugins (Remix, TanStack Start) scan source files for route discovery and write codegen outputs, while the actual bundle still uses pre-compiled .js from oxc. The staging dir becomes the Vite root automatically when staging_srcs is set. No framework-specific code in rules_typescript — one general mechanism. New: examples/remix-app/ with @remix-run/dev Vite plugin producing a real Remix SPA bundle with route-based code splitting (8 chunks, hashed filenames, Vite manifest). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 944bd08 commit ab61345

21 files changed

Lines changed: 7184 additions & 43 deletions

examples/remix-app/.bazelversion

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9.0.0

examples/remix-app/BUILD.bazel

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Root BUILD file for the examples/remix-app workspace.
2+
3+
Demonstrates:
4+
- Remix v2 SPA mode with rules_typescript
5+
- staging_srcs: copies app/ route files into a writable staging dir so
6+
the Remix vitePlugin can scan routes and write codegen
7+
- vite_config: injects the Remix Vite plugin while preserving Bazel
8+
resolve.alias and outDir integration
9+
- How framework plugins interact with the Bazel sandbox
10+
11+
Run `bazel run //:gazelle` to regenerate BUILD files.
12+
Run `bazel build //...` to compile all packages.
13+
Run `bazel build //:app_plain` to produce a plain Vite SPA bundle (no Remix).
14+
Run `bazel build //:app_remix` to produce a full Remix SPA bundle with routes.
15+
16+
── What :app_remix does ───────────────────────────────────────────────────────
17+
18+
:app_remix uses ts_bundle in app mode with:
19+
- staging_srcs = glob(["app/**/*.tsx"]) + ["package.json", "index.html"]
20+
Copies all app/ source files plus required project files into a writable
21+
_staging/ directory before Vite runs. VITE_STAGING_ROOT is set to the
22+
staging dir so the Remix vitePlugin finds app/routes/*.tsx there.
23+
- vite_config = "remix-vite.config.mjs"
24+
Injects vitePlugin() from @remix-run/dev in SPA mode (ssr: false) and
25+
sets buildDirectory = VITE_OUT_DIR so Remix writes its output into the
26+
Bazel-declared directory (app_remix_bundle/).
27+
28+
── Remix SPA mode ──────────────────────────────────────────────────────────────
29+
30+
Remix v2 supports SPA mode (ssr: false) which:
31+
- Produces only a client bundle (no server bundle).
32+
- Does not require app/entry.server.tsx or a Node.js adapter.
33+
- Outputs to buildDirectory/client/ (configurable via vitePlugin options).
34+
35+
── How the integration works ───────────────────────────────────────────────────
36+
37+
The three key pieces that make Remix work in the Bazel sandbox:
38+
39+
1. staging_srcs (writable source directory):
40+
Remix's vitePlugin() must write codegen files (route types, manifests)
41+
during the build. The Bazel source tree is read-only. staging_srcs copies
42+
source files into a writable _staging/ dir inside the action sandbox.
43+
VITE_STAGING_ROOT points to this dir; vite.root is set to it so Remix
44+
finds app/routes/*.tsx there.
45+
46+
2. HTML in staging (fixes Rollup path traversal):
47+
When staging is the Vite root, the HTML file must also be inside staging
48+
(not at ../_html_staging/). The wrapper script detects when index.html
49+
is staged and updates VITE_HTML_PATH to point to the staged copy,
50+
so rollupOptions.input uses a clean relative path with no '..'.
51+
52+
3. buildDirectory = VITE_OUT_DIR (fixes output capture):
53+
By default, Remix overrides Vite's build.outDir and writes to
54+
"<viteRoot>/build/client/". Since viteRoot = staging dir, the output
55+
would land outside the Bazel-declared directory. remix-vite.config.mjs
56+
passes buildDirectory = process.env["VITE_OUT_DIR"] to the Remix plugin,
57+
redirecting output into app_remix_bundle/client/ — inside the declared dir.
58+
59+
── What does NOT work with full SSR Remix in Bazel ────────────────────────────
60+
61+
Full SSR Remix (ssr: true, the default) requires:
62+
1. A server entry (app/entry.server.tsx).
63+
2. A separate server bundle build (outputs to buildDirectory/server/).
64+
3. A Node.js adapter (@remix-run/node or @remix-run/express).
65+
66+
The dual-output model (client + server) is architecturally incompatible with
67+
ts_bundle's single declared_directory output. Use SPA mode for Bazel-native
68+
Remix deployments.
69+
70+
── Route compilation model ──────────────────────────────────────────────────────
71+
72+
Note: Remix compiles route TypeScript via its own Vite transforms, not via
73+
Bazel's oxc compiler. The oxc-compiled .js files in bazel-bin are available
74+
via resolve.alias but Remix uses the staged .tsx files directly for routes.
75+
This is by design: staging makes source available; Remix handles compilation.
76+
The ts_compile targets (entry_client, root, routes) are still useful for
77+
incremental compilation validation and can be depended on by other targets.
78+
"""
79+
80+
load("@gazelle//:def.bzl", "gazelle")
81+
load("@rules_typescript//npm:defs.bzl", "node_modules")
82+
load("@rules_typescript//ts:defs.bzl", "ts_bundle", "ts_compile")
83+
load("@rules_typescript//vite:bundler.bzl", "vite_bundler")
84+
85+
# Run "bazel run //:gazelle" to auto-generate BUILD files.
86+
gazelle(
87+
name = "gazelle",
88+
gazelle = "@rules_typescript//gazelle:gazelle_ts",
89+
)
90+
91+
# ── Compilation targets ──────────────────────────────────────────────────────
92+
#
93+
# Each ts_compile target covers files in a single directory. This is required
94+
# by rules_typescript's oxc invocation: when multiple files are compiled
95+
# together, they must share the same strip-dir-prefix (their common directory).
96+
# Files in different subdirectories (app/ vs app/routes/) must be in separate
97+
# targets.
98+
#
99+
# The staging_srcs on ts_bundle accepts any label_list, so we can pass all
100+
# source files to glob the full app/ tree — the wrapper stages each file at
101+
# its package-relative path.
102+
103+
# Compile the Remix client entry point (single-file target — required by
104+
# ts_bundle which expects exactly 1 .js output from the entry_point).
105+
ts_compile(
106+
name = "entry_client",
107+
srcs = ["app/entry.client.tsx"],
108+
visibility = ["//visibility:public"],
109+
deps = [
110+
"@npm//:react",
111+
"@npm//:react-dom",
112+
"@npm//:remix-run_react",
113+
],
114+
)
115+
116+
# Compile the root component (single-file).
117+
ts_compile(
118+
name = "root",
119+
srcs = ["app/root.tsx"],
120+
visibility = ["//visibility:public"],
121+
deps = [
122+
"@npm//:react",
123+
"@npm//:remix-run_react",
124+
],
125+
)
126+
127+
# Compile the route components (all in app/routes/).
128+
ts_compile(
129+
name = "routes",
130+
srcs = [
131+
"app/routes/_index.tsx",
132+
"app/routes/about.tsx",
133+
],
134+
visibility = ["//visibility:public"],
135+
deps = [
136+
"@npm//:react",
137+
"@npm//:remix-run_react",
138+
],
139+
)
140+
141+
# node_modules tree with vite, remix, and their transitive deps.
142+
node_modules(
143+
name = "node_modules",
144+
deps = [
145+
"@npm//:vite",
146+
"@npm//:react",
147+
"@npm//:react-dom",
148+
"@npm//:remix-run_dev",
149+
"@npm//:remix-run_react",
150+
"@npm//:remix-run_node",
151+
],
152+
)
153+
154+
# Vite bundler instance.
155+
vite_bundler(
156+
name = "vite",
157+
vite = "@npm//:vite",
158+
node_modules = ":node_modules",
159+
)
160+
161+
# ── Plain SPA bundle (no Remix plugin) ──────────────────────────────────────
162+
# This always works: plain Vite bundles the entry_client.tsx entry point.
163+
# No route scanning, no Remix framework features.
164+
ts_bundle(
165+
name = "app_plain",
166+
mode = "app",
167+
html = "index.html",
168+
entry_point = ":entry_client",
169+
bundler = ":vite",
170+
minify = False,
171+
sourcemap = False,
172+
)
173+
174+
# ── Remix SPA bundle (staging_srcs + vite_config) ────────────────────────────
175+
#
176+
# Uses staging_srcs to copy all app/ source files into a writable staging dir.
177+
# The Remix vitePlugin (via remix-vite.config.mjs) scans routes in the staging
178+
# dir and generates the route manifest.
179+
#
180+
# Output: app_remix_bundle/client/ containing:
181+
# - index.html (Remix-generated entry with hashed asset references)
182+
# - assets/*.js (route chunks: entry.client, root, _index, about, + vendors)
183+
# - .vite/manifest.json (Vite build manifest)
184+
#
185+
# Run: bazel build //:app_remix
186+
ts_bundle(
187+
name = "app_remix",
188+
mode = "app",
189+
html = "index.html",
190+
entry_point = ":entry_client",
191+
bundler = ":vite",
192+
vite_config = "remix-vite.config.mjs",
193+
# Stage the full project structure that Remix needs:
194+
# - package.json (required by Remix's config resolver)
195+
# - app/**/*.tsx (route files for route scanning)
196+
# - index.html (HTML entry point — staged so Rollup resolves it from
197+
# the staging root without '../' path traversal)
198+
# The staging dir becomes vite.root so Remix finds:
199+
# package.json at <staging>/package.json
200+
# routes at <staging>/app/routes/*.tsx
201+
staging_srcs = glob(
202+
["app/**/*.tsx"],
203+
allow_empty = False,
204+
) + [
205+
"package.json",
206+
"index.html",
207+
],
208+
minify = False,
209+
sourcemap = False,
210+
tags = ["manual"],
211+
)

examples/remix-app/MODULE.bazel

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Remix SPA example demonstrating staging_srcs in rules_typescript.
2+
3+
Shows: React + Remix v2 SPA mode compilation, staging_srcs for route file
4+
staging, vite_config injection of the Remix Vite plugin, and how framework
5+
plugins interact with the Bazel sandbox.
6+
7+
To use from BCR, remove the local_path_override block below.
8+
"""
9+
10+
module(
11+
name = "remix_app",
12+
version = "0.0.0",
13+
)
14+
15+
bazel_dep(name = "rules_typescript", version = "0.1.0")
16+
bazel_dep(name = "rules_shell", version = "0.6.1")
17+
18+
register_toolchains("@rules_typescript//ts/toolchain:all")
19+
20+
# ─── Local development only ───────────────────────────────────────────────────
21+
# Remove this block when publishing to BCR or using rules_typescript from the
22+
# Bazel Central Registry.
23+
local_path_override(
24+
module_name = "rules_typescript",
25+
path = "../..",
26+
)
27+
28+
# ─── npm dependencies from pnpm-lock.yaml ────────────────────────────────────
29+
# Run `pnpm install` in this directory to update pnpm-lock.yaml.
30+
npm = use_extension("@rules_typescript//npm:extensions.bzl", "npm")
31+
npm.translate_lock(pnpm_lock = "//:pnpm-lock.yaml")
32+
use_repo(npm, "npm")
33+
34+
bazel_dep(name = "gazelle", version = "0.47.0")

examples/remix-app/WORKSPACE.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# This workspace uses bzlmod (MODULE.bazel) exclusively.
2+
# This file prevents Bazel from searching parent directories for a WORKSPACE
3+
# file when the module is used as a local_path_override target.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Remix client entry point.
3+
*
4+
* In Remix SPA mode (ssr: false), this file is the browser entry that
5+
* hydrates the React application.
6+
*
7+
* ── Bazel integration note ───────────────────────────────────────────────────
8+
*
9+
* This file is compiled by Bazel (ts_compile :entry_client) to produce
10+
* bazel-bin/examples/remix-app/app/entry.client.js.
11+
*
12+
* It is ALSO staged by staging_srcs into _staging/app/entry.client.tsx so
13+
* the Remix vitePlugin can discover it as the client entry.
14+
*
15+
* resolve.alias in the generated vite.config.mjs maps
16+
* "app/entry.client" → the Bazel-compiled .js. However, Remix's virtual
17+
* module system may resolve the entry differently — see BUILD.bazel for
18+
* the honest limitations.
19+
*/
20+
21+
import { RemixBrowser } from "@remix-run/react";
22+
import { startTransition, StrictMode } from "react";
23+
import { hydrateRoot } from "react-dom/client";
24+
25+
startTransition(() => {
26+
hydrateRoot(
27+
document,
28+
<StrictMode>
29+
<RemixBrowser />
30+
</StrictMode>,
31+
);
32+
});

examples/remix-app/app/root.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Root layout component for the Remix app.
3+
*
4+
* In Remix, root.tsx wraps all routes. It provides the HTML shell including
5+
* the <head>, <body>, and the <Outlet> that renders matched child routes.
6+
*/
7+
8+
import type { ReactElement, ReactNode } from "react";
9+
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
10+
11+
export function Layout({ children }: { children: ReactNode }): ReactElement {
12+
return (
13+
<html lang="en">
14+
<head>
15+
<meta charSet="utf-8" />
16+
<meta name="viewport" content="width=device-width, initial-scale=1" />
17+
<Meta />
18+
<Links />
19+
</head>
20+
<body>
21+
{children}
22+
<ScrollRestoration />
23+
<Scripts />
24+
</body>
25+
</html>
26+
);
27+
}
28+
29+
export default function App(): ReactElement {
30+
return <Outlet />;
31+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Index route — renders the home page (/).
3+
*
4+
* Remix file-based routing: this file maps to the "/" route.
5+
* The filename _index.tsx follows Remix v2 flat-file convention.
6+
*/
7+
8+
import type { MetaFunction } from "@remix-run/react";
9+
import type React from "react";
10+
11+
export const meta: MetaFunction = () => {
12+
return [
13+
{ title: "Remix + rules_typescript" },
14+
{ name: "description", content: "A minimal Remix SPA example with Bazel." },
15+
];
16+
};
17+
18+
export default function Index(): React.ReactElement {
19+
return (
20+
<div>
21+
<h1>Welcome to Remix (rules_typescript)</h1>
22+
<p>
23+
This is a minimal Remix SPA app built with Bazel and rules_typescript.
24+
Route files are staged into a writable directory so the Remix Vite plugin
25+
can scan them and generate the route manifest.
26+
</p>
27+
</div>
28+
);
29+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* About route — renders the about page (/about).
3+
*
4+
* Remix file-based routing: this file maps to the "/about" route.
5+
*/
6+
7+
import type { MetaFunction } from "@remix-run/react";
8+
import type React from "react";
9+
10+
export const meta: MetaFunction = () => {
11+
return [{ title: "About | Remix + rules_typescript" }];
12+
};
13+
14+
export default function About(): React.ReactElement {
15+
return (
16+
<div>
17+
<h1>About</h1>
18+
<p>
19+
This example demonstrates Remix SPA mode with Bazel and rules_typescript.
20+
Routes are staged into a writable directory before bundling, allowing the
21+
Remix Vite plugin to discover and process them.
22+
</p>
23+
</div>
24+
);
25+
}

examples/remix-app/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Remix SPA (rules_typescript)</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="./app/entry.client.js"></script>
11+
</body>
12+
</html>

0 commit comments

Comments
 (0)