Soklet Logo

Core Concepts

Static Files

Soklet provides StaticFiles for serving files from a configured directory without hand-rolling path containment, validators, or byte-range handling. It is still a library primitive: you decide which route exposes the files, what a miss means, and which cache policy applies to each file.

Use StaticFiles::withRoot when you are exposing a static root such as bundled web assets. If application code has already resolved one trusted file, see Marshaled File Responses for the lower-level single-file helper.

Serving A Static Root

// Serve up some static files from a directory
public final class AssetResource {
  private final StaticFiles staticFiles;

  public AssetResource() {
    // Build once and reuse; StaticFiles is thread-safe
    this.staticFiles = StaticFiles.withRoot(Path.of("public"))
      .indexFileNames(List.of("index.html"))
      // Revalidate HTML, but let fingerprinted assets live at the edge
      .cacheControlResolver((path, attributes) ->
        path.getFileName().toString().endsWith(".html")
          ? Optional.of("no-cache")
          : Optional.of("public, max-age=31536000, immutable"))
      // Apply a small default hardening header to every file
      .headersResolver(HeadersResolver.fromHeaders(
        Map.of("X-Content-Type-Options", Set.of("nosniff"))))
      .build();
  }

  @GET("/assets/{assetPath*}")
  public MarshaledResponse asset(
    Request request,
    @PathParameter String assetPath
  ) {
    // Let StaticFiles handle safe resolution, validators, ranges, and HEAD
    return staticFiles.marshaledResponseFor(assetPath, request)
      .orElseGet(() -> MarshaledResponse.fromStatusCode(404));
  }
}

The route uses a Varargs Path Parameter, so /assets/app.js, /assets/images/logo.png, and deeper paths all bind to assetPath, a @PathParameter. A HEAD request can use the same @GET route: Soklet invokes the matching GET Resource Method for HEAD, passes the original Request to your method, then applies normal HTTP HEAD response handling.

StaticFiles::marshaledResponseFor returns Optional.empty() for request methods other than GET and HEAD, for missing files, and for rejected paths. The application chooses whether that becomes a 404, a custom fallback, or another response; the sample uses MarshaledResponse::fromStatusCode for its fallback.

OPTIONS Is Route-Level

StaticFiles does not implement a per-file OPTIONS policy. Soklet's normal automatic OPTIONS handling is based on route matches, not filesystem existence. An OPTIONS /assets/app.js and an OPTIONS /assets/missing.js both match the route above and can advertise GET, HEAD, and OPTIONS; the later GET decides whether the file exists.

Path Safety

The configured root is the trust boundary. StaticFiles resolves each request path under that root and collapses unsafe or missing inputs to Optional.empty().

Rejected inputs include:

  • empty paths when no index file is configured
  • absolute paths
  • Windows drive-like and UNC-like paths
  • backslashes
  • control characters
  • path segments exactly equal to ..
  • paths longer than 4096 UTF-8 bytes
  • paths with more than 256 slash-separated segments

Segments may begin with .. For example, .well-known is allowed; only a segment exactly equal to .. is treated as traversal.

If the candidate resolves to a directory, configured indexFileNames are tried in order. Resolvers receive the final file path after index resolution, not the original relative path.

Relative roots are resolved against the JVM's current working directory at build time. Production deployments should prefer absolute roots so startup behavior does not depend on how the process was launched.

Symlinks are not followed by default. In the default mode, any symlink component in the candidate path is rejected before serving. If you opt in with Builder::followSymlinks, Soklet follows symlinks but still requires the final real path to remain under the canonical root.

Static roots should be treated as application-owned files, not as directories writable by untrusted users while requests are being served. If files are deleted or replaced between path resolution and response writing, the in-flight write can fail or serve the replacement content.

Cache And Range Behavior

By default, StaticFiles emits:

  • a weak ETag derived from the file's whole-second last-modified time and size
  • a Last-Modified header from file attributes
  • Accept-Ranges: bytes
  • no Cache-Control header

The default weak ETag is deterministic for multiple Soklet instances serving the same shared filesystem metadata, which helps cache revalidation behave consistently behind a load balancer.

If you need a strong validator for resumable downloads or immutable assets, opt into EntityTagResolver::fromContentHash:

StaticFiles downloads = StaticFiles.withRoot(Path.of("/srv/downloads"))
  // Strong ETags let resumable downloads use If-Range safely
  .entityTagResolver(EntityTagResolver.fromContentHash())
  .cacheControlResolver(CacheControlResolver.fromValue("public, max-age=86400"))
  .build();

The content-hash resolver emits strong ETags in the form sha256-<lowercase-hex>. It streams the file through SHA-256 with a fixed-size buffer and does not load the full file into memory, but it still reads the full file on the request-handling thread. HEAD requests also compute the hash even though no body is sent. For large files, HEAD-heavy traffic, or large static roots, prefer a manifest-backed EntityTagResolver.

// Use a custom manifest when request-time hashing is too expensive
Map<Path, EntityTag> entityTagsByPath = loadManifest();

StaticFiles staticFiles = StaticFiles.withRoot(Path.of("public"))
  // Resolvers see the resolved file path, so key the manifest the same way
  .entityTagResolver((path, attributes) -> Optional.ofNullable(entityTagsByPath.get(path)))
  .build();

Resolvers receive the resolved filesystem path. If you use a manifest, key it with the same resolved path form or convert the path before lookup.

Soklet evaluates normal HTTP preconditions for file responses:

  • If-Match and If-Unmodified-Since can produce 412 Precondition Failed
  • If-None-Match and If-Modified-Since can produce 304 Not Modified
  • a satisfiable single byte Range can produce 206 Partial Content
  • an unsatisfiable byte Range can produce 416 Range Not Satisfiable

Only a single byte range is supported. Multipart byte ranges are not currently implemented. Range and If-Range are honored for GET only. If-Range honors only strong ETags or an HTTP date that exactly matches Last-Modified; weak ETags and nonmatching dates fall back to the full 200 OK response.

For HEAD, Range is ignored. A HEAD request with Range: bytes=2-4 returns the same status and representation headers as the full-file GET response would have returned, then ResponseMarshaler::forHead strips the body and sets Content-Length to the full file size.

No Cache-Control header does not mean "no browser caching." Browsers may still apply heuristic caching. If you need explicit opt-out behavior, configure a resolver that returns no-store or no-cache.

Per-Path Policy

Static roots often need different policy by file type. Hashed JavaScript and CSS can be cached for a long time, while HTML usually should revalidate. StaticFiles uses resolver functions so that policy can be based on the resolved file path and already-read BasicFileAttributes.

The resolver hooks are:

Factory helpers cover common constant policies:

StaticFiles staticFiles = StaticFiles.withRoot(Path.of("/srv/app/public"))
  // Constant-policy factories keep the common case compact
  .cacheControlResolver(CacheControlResolver.fromValue("public, max-age=300"))
  .headersResolver(HeadersResolver.fromHeaders(
    Map.of("X-Content-Type-Options", Set.of("nosniff"))))
  .rangeRequestsResolver(RangeRequestsResolver.enabledInstance())
  .build();

The sample uses CacheControlResolver::fromValue, HeadersResolver::fromHeaders, and RangeRequestsResolver::enabledInstance to keep the constant-policy case terse.

Passing null to a resolver setter restores the default resolver. Resolver methods must not return null; use Optional.empty() or an empty map to omit a value. If a resolver throws, the exception follows Soklet's standard exception path and no file body has been selected yet.

User-supplied resolvers must be thread-safe because StaticFiles invokes them concurrently from request-handling threads. They also run on those request-handling threads, so expensive work such as content hashing, database access, or network calls should be cached or precomputed.

Access Decisions

By default, any safe, readable regular file under the configured root is allowed. Configure Builder::accessResolver when you need additional path- or attribute-based policy after Soklet has already resolved the path safely:

StaticFiles staticFiles = StaticFiles.withRoot(Path.of("public"))
  .accessResolver((path, attributes) -> {
    String fileName = path.getFileName().toString();

    // Hide secrets as though they were not present
    if (fileName.equals(".env"))
      return Access.HIDE;

    // Acknowledge the file, but deny access to it
    if (fileName.endsWith(".internal.json"))
      return Access.DENY;

    return Access.ALLOW;
  })
  .build();

Access has three outcomes:

  • ALLOW: continue normal static-file response generation
  • HIDE: return Optional.empty(), so your resource method can use its normal 404 fallback
  • DENY: return a bodyless 403 Forbidden MarshaledResponse

The access resolver receives the resolved file path and BasicFileAttributes. It does not receive the Request; request-aware checks such as cookies, users, origins, or tenants should happen in your resource method or another application layer before calling StaticFiles::marshaledResponseFor.

Access is checked after index-file resolution. If indexFileNames is ["index.html", "index.htm"] and index.html resolves but returns HIDE or DENY, Soklet does not fall back to index.htm.

DENY does not invoke headersResolver; if denial responses need security headers, add them at a higher layer. HIDE uses the same Optional.empty() contract as missing files, but it still performs path resolution and attribute checks before deciding. Do not rely on HIDE to defeat timing-side-channel observation.

The headersResolver result is the final extra-header set for the response. If you need global headers plus per-path headers, merge them inside the resolver. Headers controlled by the file response protocol cannot be supplied through headersResolver; use the dedicated resolver or builder method instead.

Controlled headers include:

  • Content-Length
  • Content-Range
  • Accept-Ranges
  • Content-Type
  • Content-Encoding
  • Cache-Control
  • ETag
  • Last-Modified
  • Transfer-Encoding

MIME Types

StaticFiles uses a curated deterministic extension table for common web assets by default. It does not call the JDK's Files.probeContentType(...), because that behavior can vary by host operating system and local MIME configuration.

Configuring Builder::mimeTypeResolver fully replaces the default. If your resolver returns Optional.empty(), Soklet omits Content-Type; it does not fall back to another resolver.

To extend the default table, delegate to MimeTypeResolver::defaultInstance for files your resolver does not handle:

MimeTypeResolver defaultResolver = MimeTypeResolver.defaultInstance();

StaticFiles staticFiles = StaticFiles.withRoot(Path.of("public"))
  // Handle your custom extension first, then delegate to Soklet's defaults below
  .mimeTypeResolver((path) -> path.getFileName().toString().endsWith(".myext")
    ? Optional.of("application/x-myext")
    : defaultResolver.contentTypeFor(path))
  .build();

If the content type is unknown, Soklet omits Content-Type. For untrusted user uploads, prefer a custom resolver that returns application/octet-stream for unknown files and emit X-Content-Type-Options: nosniff via headersResolver. If you want host/JDK MIME probing, call Files.probeContentType(...) from your own resolver and handle IOException according to your application's policy.

The default resolver recognizes these extensions, grouped the same way they are maintained in code.

Web Documents And Scripts

ExtensionsContent-Type
.html, .htmtext/html; charset=UTF-8
.csstext/css; charset=UTF-8
.js, .mjstext/javascript; charset=UTF-8

Web Application Data

ExtensionsContent-Type
.jsonapplication/json; charset=UTF-8
.mapapplication/json
.webmanifestapplication/manifest+json
.txttext/plain; charset=UTF-8
.xmlapplication/xml; charset=UTF-8
.xhtmlapplication/xhtml+xml
.atomapplication/atom+xml
.rssapplication/rss+xml
.csvtext/csv; charset=UTF-8
.md, .markdowntext/markdown; charset=UTF-8
.yaml, .ymlapplication/yaml
.jsonldapplication/ld+json
.ndjsonapplication/x-ndjson

Images

ExtensionsContent-Type
.svgimage/svg+xml
.pngimage/png
.jpg, .jpegimage/jpeg
.gifimage/gif
.webpimage/webp
.avifimage/avif
.jxlimage/jxl
.heicimage/heic
.heifimage/heif
.apngimage/apng
.bmpimage/bmp
.tiff, .tifimage/tiff
.icoimage/x-icon

Documents And Binaries Commonly Served By Web Apps

ExtensionsContent-Type
.pdfapplication/pdf
.wasmapplication/wasm

Fonts

ExtensionsContent-Type
.wofffont/woff
.woff2font/woff2
.ttffont/ttf
.otffont/otf

Audio

ExtensionsContent-Type
.mp3audio/mpeg
.wavaudio/wav
.oggaudio/ogg
.m4aaudio/mp4
.aacaudio/aac
.flacaudio/flac
.opusaudio/opus

Video

ExtensionsContent-Type
.mp4, .m4vvideo/mp4
.webmvideo/webm
.ogvvideo/ogg
.movvideo/quicktime

Streaming Manifests And Captions

ExtensionsContent-Type
.m3u8application/vnd.apple.mpegurl
.mpdapplication/dash+xml
.vtttext/vtt; charset=UTF-8

Case sensitivity follows the underlying filesystem. Production deployments should standardize asset names, commonly lowercase, to avoid OS-dependent behavior.

Marshaled File Responses

MarshaledResponse::withFile is not another static-root API. It is the lower-level helper for one already-resolved, trusted file when you still want HTTP file-response behavior.

This is different from the raw file-body APIs in Zero-Copy Responses: MarshaledResponse.Builder::body(Path) only attaches a file as the body, while withFile(...) evaluates the current Request and can return 200, 206, 304, 412, or 416 with the appropriate file headers. Its builder uses direct values rather than resolvers because application code has already chosen the file and can compute the one response policy it wants.

@GET("/report.pdf")
public MarshaledResponse report(Request request) {
  // The application has already chosen this trusted file
  Path report = Path.of("/srv/app/reports/current.pdf");

  return MarshaledResponse.withFile(report, request)
    // Supply the policy for this one file directly
    .contentType("application/pdf")
    .cacheControl("private, no-cache")
    .entityTag(EntityTag.fromStrongValue("report-v42"))
    .lastModified(Instant.parse("2026-05-04T12:00:00Z"))
    .build();
}

The example starts with MarshaledResponse::withFile and uses EntityTag::fromStrongValue for an application-defined strong validator.

Files get a custom MarshaledResponse.FileBuilder because file responses are request-aware: validators, byte ranges, If-Range, and HEAD behavior depend on the current Request and filesystem metadata. Other known-length body types do not need that protocol layer and should use the regular MarshaledResponse.Builder::body overloads.

withFile(...) applies the same conditional request and single-range behavior as StaticFiles. It does not perform static-root containment checks; use StaticFiles when client input participates in path resolution.

Non-Goals

StaticFiles intentionally does not provide:

  • directory listings
  • trailing-slash redirects
  • compression or precompressed asset negotiation
  • multipart byte ranges
  • per-file OPTIONS handling
  • content negotiation or automatic Vary headers
Previous
Response Writing