chore: version packages - #245
Merged
Merged
Conversation
adesege
reviewed
Jul 27, 2026
adesege
left a comment
Contributor
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: 4a953f1 | Previous: ccb3f17 | Ratio |
|---|---|---|---|
test/benchmarks/request-response.bench.ts > Request/Response > simple GET - 200 |
36969.844135137144 ops/sec (±1.32%) |
44355.827899388205 ops/sec (±1.52%) |
1.20 |
test/benchmarks/request-response.bench.ts > Request/Response > GET with route params - 200 |
45996.258581652866 ops/sec (±0.62%) |
49077.55133303189 ops/sec (±1.07%) |
1.07 |
test/benchmarks/request-response.bench.ts > Request/Response > POST with JSON body - 201 |
16285.125619036411 ops/sec (±1.18%) |
24127.579263272528 ops/sec (±1.28%) |
1.48 |
test/benchmarks/request-response.bench.ts > Request/Response > POST invalid body - validation error |
4010.3241256510996 ops/sec (±3.11%) |
4332.798367697519 ops/sec (±3.08%) |
1.08 |
test/benchmarks/request-response.bench.ts > Request/Response > GET unknown route - 404 |
5999.544886524008 ops/sec (±1.44%) |
6877.491973422911 ops/sec (±1.38%) |
1.15 |
src/__benchmarks__/application.bench.ts > Application - Bootstrap > constructor only |
369828.7943581282 ops/sec (±0.64%) |
367439.1967778971 ops/sec (±2.58%) |
0.99 |
src/__benchmarks__/application.bench.ts > Application - Bootstrap > full initialize() |
36418.94472465844 ops/sec (±1.20%) |
40916.51636711715 ops/sec (±2.82%) |
1.12 |
src/__benchmarks__/application.bench.ts > Application - Service Resolution > resolve service after bootstrap |
42820.22578676439 ops/sec (±1.37%) |
46221.77899438597 ops/sec (±1.51%) |
1.08 |
src/__benchmarks__/application.bench.ts > Application - Multi-Controller Bootstrap > initialize with 5 controllers (8 routes) |
42603.526163578375 ops/sec (±0.72%) |
47297.584538015675 ops/sec (±0.94%) |
1.11 |
src/__benchmarks__/application.bench.ts > Application - Multi-Controller Bootstrap > resolve service after multi-controller bootstrap |
41885.811932707955 ops/sec (±0.65%) |
46712.398512126 ops/sec (±0.86%) |
1.12 |
src/di/__benchmarks__/container.bench.ts > Container - Registration > register class provider |
4271033.145793519 ops/sec (±1.68%) |
3659523.004609583 ops/sec (±0.26%) |
0.86 |
src/di/__benchmarks__/container.bench.ts > Container - Registration > registerSingleton |
4026860.8724799873 ops/sec (±2.13%) |
3674735.919157303 ops/sec (±0.23%) |
0.91 |
src/di/__benchmarks__/container.bench.ts > Container - Registration > registerValue |
4580004.16800319 ops/sec (±2.24%) |
3643329.0527346027 ops/sec (±0.97%) |
0.80 |
src/di/__benchmarks__/container.bench.ts > Container - Registration > registerFactory |
4670144.692362833 ops/sec (±0.74%) |
3731349.291036125 ops/sec (±0.79%) |
0.80 |
src/di/__benchmarks__/container.bench.ts > Container - Resolution > resolve class token |
1407599.000603626 ops/sec (±0.42%) |
1231183.1036989056 ops/sec (±0.71%) |
0.87 |
src/di/__benchmarks__/container.bench.ts > Container - Resolution > resolve symbol token |
1424788.6749439533 ops/sec (±0.41%) |
1316018.4734202325 ops/sec (±0.72%) |
0.92 |
src/di/__benchmarks__/container.bench.ts > Container - Resolution > resolve value token |
1933069.095325256 ops/sec (±2.28%) |
1783899.5774658315 ops/sec (±0.30%) |
0.92 |
src/di/__benchmarks__/container.bench.ts > Container - Resolution > resolve singleton token |
1352330.2527890466 ops/sec (±0.43%) |
1315930.5814242992 ops/sec (±0.55%) |
0.97 |
src/di/__benchmarks__/container.bench.ts > Container - Resolution > isRegistered check |
2106508.9635955375 ops/sec (±0.20%) |
1806360.778904609 ops/sec (±0.19%) |
0.86 |
src/di/__benchmarks__/container.bench.ts > Container - Conditional Binding > when().use().give().otherwise() |
2462564.419034541 ops/sec (±3.00%) |
1989351.3462046375 ops/sec (±2.25%) |
0.81 |
src/di/__benchmarks__/container.bench.ts > Container - Conditional Binding > when() with cached predicate |
2439603.0290414556 ops/sec (±0.63%) |
2077218.3049918814 ops/sec (±0.64%) |
0.85 |
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Registration > register single module |
1353256.167691235 ops/sec (±0.62%) |
1318623.2747570868 ops/sec (±0.43%) |
0.97 |
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Registration > register 3-level module tree |
679841.4819609317 ops/sec (±0.44%) |
674368.8967327109 ops/sec (±0.61%) |
0.99 |
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Registration > register dynamic module (forRoot) |
1132211.8075242015 ops/sec (±0.39%) |
1033574.8692689876 ops/sec (±0.46%) |
0.91 |
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Initialization > initialize with lifecycle hooks |
820519.6717920463 ops/sec (±0.57%) |
861264.3797109328 ops/sec (±0.67%) |
1.05 |
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Collection > getAllControllers |
603021.1461219145 ops/sec (±0.47%) |
601067.2979531069 ops/sec (±0.70%) |
1.00 |
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Collection > getAllConsumers |
599319.9916095391 ops/sec (±0.44%) |
598005.5897686194 ops/sec (±2.06%) |
1.00 |
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Collection > getAllJobs |
602324.2990362238 ops/sec (±0.40%) |
606220.5693193878 ops/sec (±0.44%) |
1.01 |
src/router/__benchmarks__/route-registration.bench.ts > RouteRegistration - Configure > register controller with 5 OpenAPI routes |
16893.46751790366 ops/sec (±6.80%) |
20659.201604495036 ops/sec (±7.81%) |
1.22 |
src/router/__benchmarks__/route-registration.bench.ts > RouteRegistration - Configure > register single-route controller |
84336.03405772212 ops/sec (±8.21%) |
87549.06480088776 ops/sec (±9.59%) |
1.04 |
src/router/__benchmarks__/route-registration.bench.ts > RouteRegistration - Configure > register multiple controllers |
17238.784700155928 ops/sec (±6.85%) |
20055.348642388202 ops/sec (±7.48%) |
1.16 |
src/router/__benchmarks__/route-registration.bench.ts > Route Sorting > sort 10 routes by specificity |
334083.91447458765 ops/sec (±1.72%) |
348297.84898624144 ops/sec (±1.02%) |
1.04 |
src/router/__benchmarks__/route-registration.bench.ts > Route Sorting > sort 50 routes by specificity |
63716.075009950684 ops/sec (±0.38%) |
66342.982298651 ops/sec (±0.49%) |
1.04 |
src/router/__benchmarks__/route-registration.bench.ts > Route Sorting > sort 100 routes by specificity |
30807.01343620418 ops/sec (±0.38%) |
32949.193140155745 ops/sec (±0.54%) |
1.07 |
src/router/__benchmarks__/route-registration.bench.ts > Param Extraction > extractParamNames - static path |
12304712.0558419 ops/sec (±0.13%) |
12503091.774915366 ops/sec (±0.09%) |
1.02 |
src/router/__benchmarks__/route-registration.bench.ts > Param Extraction > extractParamNames - single param |
1801191.7982674923 ops/sec (±0.44%) |
2209847.5226742206 ops/sec (±1.46%) |
1.23 |
src/router/__benchmarks__/route-registration.bench.ts > Param Extraction > extractParamNames - multiple params |
1295222.9353269946 ops/sec (±0.65%) |
1332989.981337998 ops/sec (±0.60%) |
1.03 |
This comment was automatically generated by workflow using github-action-benchmark.
github-actions
Bot
force-pushed
the
changeset-release/main
branch
from
September 20, 2026 19:17
4a953f1 to
1e4b937
Compare
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: Repository: strataljs/stratal/.coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
[email protected]
Minor Changes
a753e55: Move routing onto plain Hono with lazy OpenAPI generation, add declarative response caching on Cloudflare Workers Caching, and add per-path locale detection.
Routing and validation
params,queryorbody, so a schema-less route pulls in none of it: a hello-world worker drops from 944 KB to 504 KB raw.ctx.param(),ctx.query()andctx.body()are unchanged.describe()andnamed(), not just a description string —example,examples,titleanddeprecatedall flow through to the generated OpenAPI document.groups.@Controllerand route options take agroups: string[]label list, exposed on each route's schema metadata, so the OpenAPIrouteFiltercan scope a document by group instead of by path string.Response caching
Add declarative HTTP response caching through the new
stratal/response-cacheentry. On a cache hit the Worker never runs, so no CPU is billed.@Cacheable({ ttl, browserTtl, swr, tags, vary })onGETandHEADroutes emitsCDN-Cache-ControlandCache-ControlalongsideCache-Tag.browserTtldefaults tottl, which is what makes a repeat visit free rather than a round trip; setbrowserTtl: 0where retraction has to be reliable, since tags andctx.cache.purge()reach the shared cache and nothing else.@PurgesCache({ tags, pathPrefixes, purgeEverything })purges after a2xxor3xx. The purge is awaited and a failure is rethrown asCachePurgeError, rather than leaving the cache silently inconsistent with the database.ResponseCacheModule.forRoot({ defaults })suppliesttl,swrandvaryfor every@Cacheableroute. Defaults never make a route cacheable on their own —@Cacheablestays mandatory.{param.*},{query.*},{data.*}and{partition.*}into cache tags, with a.*suffix fanning an array out to one tag per element. A rendered tag must be printable ASCII with no space, comma or double quote and at most 1024 bytes, or it throwsInvalidCacheTagError— slugify any request-derived value before interpolating it.@Cacheable({ partitionBy: [...] }). ExportcachedEntrypoint(stratal)fromstratal/workersalongside your default export, then configuregateway: { entrypoint: 'Cached' }withpartitionsandprimers. Partitioned reads are forwarded to that entrypoint, which places the resolved partitions in the part of the cache key that cannot be bypassed.gateway.entrypointis type-checked against your Worker's real exports once you have runwrangler types. A guarded route is only ever cacheable with a non-emptypartitionBy, and a partition that fails to resolve runs inline and is stampedprivate, no-storerather than being cached publicly.gateway: { keyBy: [...] }, so one URL answered two ways is two entries rather than one entry with two variants. An Inertia app behind a gateway should setkeyBy: INERTIA_VARY_HEADERS, exported from@stratal/inertia; without it those pages stop caching.X-RateLimit-*from a response a shared cache may store. They describe one caller's budget, so on a shared response they are replayed to every other caller, and a cache hit never runs the throttle to count them down. The limit is still consumed and enforced; only the reporting is withheld. AddsisSharedCacheableandPER_CALLER_RATE_LIMIT_HEADERS."cache": { "enabled": true }inwrangler.jsonc, Wrangler 4.69.0 or newer, and acompatibility_dateof2026-07-06or later. Without those a@Cacheableroute is served uncached and stampedprivate, no-store, and the reason is logged once per entrypoint.ResponseCacheConfigError,CachePurgeError,InvalidCacheTagError.Storage
head(),list()anddeleteMany()toStorageService.head(path, disk?)reads an object's size, content type, etag, upload time and custom metadata without transferring its body, ornullwhen nothing is stored there.list(options?, disk?)pages objects under a disk-relativeprefixand carriestruncatedandcursor, so loop whiletruncatedis true to cover a whole prefix.deleteMany(paths, disk?)deletes in bulk instead of one call per file.text/html, or as a scriptableimage/svg+xml, previously executed against whatever session fetched it, since objects are served from the application's own origin. Onlyapplication/pdf,image/png,image/jpeg,image/gifandimage/webpnow render inline; everything else returns as an attachment. Every download also carriesX-Content-Type-Options: nosniffand a sandboxingContent-Security-Policy.#or?— most user-supplied filenames — being reported as missing, and stop a key containing a control character producing a malformed header. Non-ASCII filenames are preserved.Internationalisation
detectionaccepts a(path) => optionsresolver, alongsideI18nModule.forRootAsyncand a strategy-awarectx.setLocale. Different areas can now use different strategies — a path-localized public site with a cookie-localized/adminpanel, say — which is necessary when an area's session cookie is path-scoped. Only routes whose path resolves tostrategy: 'path'get a/:localevariant; everything else is served at its bare path with no change to URL builders. The resolver must be a pure function of the path, since it is consulted both at boot and per request.localecookie by the resolvedcookieOptions, so a per-path cookie area writes{ path: '/admin' }. Plainstrategy: 'cookie'behaviour is unchanged.Quarry CLI
.wrangler/state/v3/<plugin>, so existing KV, D1, R2, Durable Object and cache state carries over and is still shared with a runningwrangler dev.process.envinto worker vars and secrets, so CI and scripted runs that pass config through the environment no longer fail validation on a missing binding. Local runs with a.dev.varsare unchanged.The Workers runtime failed to starton a worker that declares a Cloudflare Workflow. Workflow bindings are stripped from the host and logged — trigger workflows from the worker that defines them.mcp:serveandmcp:toolsfailing to start: both built the OpenAPI document from the root container, but commands run in a request scope and the document needs the request-scoped OpenAPI config service.Other fixes
Limit.distinctBy(value)counts distinct values in the window instead of requests, for caps like "ten different courses a day".Responsereturned by a short-circuiting middleware even when an outer middleware forwards control withawait next()and discards the result. An earlyctx.redirect(...)was previously dropped, leaving the request unfinalized and throwing "Context is not finalized".Nextis widened to() => Promise<Response | void>so a forwarding middleware canreturn next()without a cast.reportContext()hook onApplicationError. A failed validation now logs which field failed and why, where it previously logged only a generic line./openapi.jsonfailing when a route schema contains a type with no JSON Schema representation, such asz.custom,z.dateorz.set. Those emit an empty schema instead of throwing, so one unrepresentable field no longer takes down the whole document.openapi3-tsas a direct dependency, which a clean install such as CI could not otherwise resolve.Breaking Changes
zod/mini. Thezre-export fromstratal/validationis removed. Import schema builders directly fromzod/miniusing named imports and replace classic chaining with the functional API:z.string().min(1).optional()becomesoptional(string().check(minLength(1))).stratal/validationstill exportscuid2andwithZodI18n, plusdescribe()andnamed()for descriptions and OpenAPI component ids, sincezod/minihas no.describe()or.meta().OpenAPIService.getSpec()becomesgetSpec(container)and is async — update any direct call.routeFilteris now a metadata predicate(route: RouteSchemaMeta) => booleaninstead of(path, pathItem); filter onroute.groupsorroute.metarather than on the path string.Cache-Controlheader. Routes without@Cacheableare stampedprivate, no-store. This affects every app, not only those adopting caching: Cloudflare applies heuristic freshness to a response carrying noCache-Controlat all, caching a200for two hours, so the explicit header is what keeps an uncacheable route uncached. Routes that set their ownCache-Controlare left alone — if you relied on a response having none, set one explicitly.CacheService.putis now fire-and-forget and can no longer report failure. It schedules the write, resolves immediately and logs a rejection instead of throwing, sotry { await cache.put(...) } catch { … }now sees success even when the value was never stored. A cache is best-effort, and a KV write can add hundreds of milliseconds to a request, so this is the right default — but move any write that must not be silently lost toCacheService.putDurable/TieredCacheService.putDurable, which await the write and throw on failure.deleteis unchanged and remains durable and awaited.application/pdf,image/png,image/jpeg,image/gifandimage/webprender inline; everything else downloads as an attachment. If you relied on another type rendering in the browser, serve that content from a separate origin, where a compromise cannot reach the application's session.canActivatereturnsfalse, withGuardRejectedError(403), instead of the return value being ignored. Audit yourcanActivateimplementations before upgrading — requests that previously reached the handler now 403.GuardRejectedErroris also re-exported from@stratal/framework/guards.npx quarrywill start.@stratal/[email protected]
Minor Changes
a753e55: Add cursor pagination, share permissions with the client for Inertia access control, and add a Workers-safe database pool factory.
Cursor pagination
Add
db.$cursorfor reading a list one page at a time, positioned by an opaque cursor rather than an offset.orderByis required and must end in a unique column, so tied rows do not share a position.where,select,includeandomitwork as on the model's ownfindMany, andselectnarrows the result type.@stratal/inertia'sctx.scroll()as it is, or return it from a JSON route. Cursors are opaque — pass back the one a result gave you.db.$cursor.$from({ findMany }, …)pages aUNIONor a raw statement.cursorargument, which is offset-based and correct only while the list is unchanged.Access control and auth
Share the current user's permissions and roles automatically once
accessControlis configured, so the client can gate on them. This backs the<Can>,<Cannot>,<HasRole>and<HasNoRole>components and theuseCan,useRoleanduseAccesshooks in@stratal/inertia, with permission strings and role names type-checked against a generated registry.Add
AUTH_GATEWAY_PRIMERS, exported from@stratal/framework/auth, so guarded and per-tenant routes can use@Cacheable({ partitionBy: [...] }). The response-cache gateway resolves partitions outside the app's middleware chain, so a resolver callingctx.user()would otherwise throw on every request:Carry the cookies a session read issues through to the response. The
Set-CookieBetter Auth writes while reading a session was previously discarded, so undersession.cookieCachethe cached-session cookie was minted on every request and reached the browser on none, and a session passingsession.updateAgenever delivered its extended expiry — a browser's copy expired on the schedule it was first given rather than sliding. Cookie names the handler has already written are left alone, so sign-out still clears them.Fix role reads and writes failing for any app whose ZenStack user model is not named exactly
User. Setting a user's role, reading another user's roles, checking a permission and listing a user's permissions all threw when the model resolved to a different accessor, such as a pluralizedUsers. Changing a role now also refreshes that user's sessions, so it takes effect immediately.Adapt the Better Auth rate-limit bridge to the new atomic
consumestorage.createBetterAuthRateLimitStorage()now returns{ consume }, and records expire after the rule's own window instead of a fixed day, so stale counters no longer linger in KV. Accuracy follows the configured store, exactly as Stratal's own throttling does: exact in memory, best-effort on KV, where concurrent writes from different edge locations may undercount. If you pass your ownrateLimit.customStorage, it must now implementconsume— Better Auth no longer acceptsget/set.Database
createPoolFactory(env, makePool)to@stratal/framework/database, which chooses connection topology from the environment instead of hard-coding it. Writeconst pool = createPoolFactory(env, () => new Pool(config)), thendialect: () => new PostgresDialect({ pool }). By default it returns a fresh pool per resolution, which is mandatory on the Workers runtime, where a pool opened in one request's I/O context cannot be reused by a later one without the runtime cancelling the cross-request I/O and hanging the request. The pool is created lazily on first query, so nothing opens a socket at module scope. In production Hyperdrive fronts these pools, so they never accumulate.$transactionguard effective across services, where separate clients could previously deadlock on a small pool.DatabaseModule.forRootAsync. A factory that actually returned a promise handed initialization aPromiseand it walkedundefinedconnections. An asynchronous factory now works as documented, which is what lets a consumer put a generated schema behind animport()rather than evaluating a large schema module while the isolate starts.Breaking Changes
zod/mini. Thezre-export is gone from the validation surface this package re-exports. Import schema builders directly fromzod/miniusing named imports and replace classic chaining with the functional API:z.string().min(1).optional()becomesoptional(string().check(minLength(1))). Usedescribe()andnamed()fromstratal/validationfor descriptions and OpenAPI component ids.OpenAPIService.getSpec()becomesgetSpec(container)and is async, androuteFilteris now a metadata predicate(route: RouteSchemaMeta) => booleaninstead of(path, pathItem).canActivatereturnsfalse, withGuardRejectedError(403), instead of the return value being ignored. Audit yourcanActivateimplementations before upgrading — requests that previously reached the handler now 403.GuardRejectedErroris re-exported from@stratal/framework/guards, so apps that standardise on that path caninstanceofit without a second import.rateLimit.customStoragemust implementconsume, replacing the previousget/setpair.Patch Changes
@stratal/[email protected]
Minor Changes
a753e55: Add build-time SSR exclusion, client-side access control and
ctx.scroll()for infinite scroll, and make Inertia pages cacheable.Build-time SSR exclusion
Add
ssrExcludeto thestratalInertia()Vite plugin. Client-only pages and their heavy dependencies were previously always bundled into the worker, because the SSR page glob pulled in every page; disabling SSR at runtime skipped rendering but still shipped the code.Patterns are matched against the page name, where
*is a single segment and**any number. Excluded pages are dropped from the worker bundle and rendered client-only, while the browser bundle still includes them so they hydrate normally.Apply
ssrExcludeto an array-form page glob such asimport.meta.glob(['./pages/**/*.tsx', '!...']), keeping the negative patterns it already had. Only the single-string form worked before, so an array-form resolver silently kept every page in the worker bundle. A glob that cannot be rewritten now emits a build warning naming the file.Rewrite
import.meta.globresolvers that pass a second argument, such as{ eager: true }, preserving those options.Client-side access control
<Can>,<Cannot>,<HasRole>and<HasNoRole>components plus theuseCan,useRoleanduseAccesshooks, on a new@stratal/inertia/react/accessentry. They are gated on permissions the server shares automatically onceaccessControlis configured, and permission strings and role names are type-checked against a generated registry.Infinite scroll
Add
ctx.scroll(callback, options?)for Inertia v3 infinite scroll, which makes@inertiajs/react's<InfiniteScroll>work against a Stratal route. Until now the page carried no scroll metadata at all and the component threw before rendering.paginatedResponseSchema, and@stratal/framework'sdb.$cursorresult. Any other shape throwsUnrecognizedScrollShapeErrorrather than guessing, because a wrong next page reads to the client as "no more pages" and silently truncates the list. Passmetadatato name the identifiers for a third-party shape.wrapper(defaultdata) accumulate. Options arewrapper,matchOn,pageNameandmetadata.matchOnhas never deduplicated anything. Entries were emitted in a form the client resolved to no prop, so every merge fell back to plain concatenation. A row that changes between two pages of a merged list is now collapsed instead of appearing twice.X-Inertia-Resetis now honoured. The header was parsed and discarded, so a prop the client named in it was joined to rather than replaced.assertInertiaScrollProp(prop, expected?)assertion and exportsUnrecognizedScrollShapeErroralongside the scroll option and metadata types.Caching
ctx.defer()prop, by declaring the Inertia protocol headers inVaryon every response. Deferred props are delivered by a follow-up partial reload, and those were refused outright, so a page that defers its expensive work kept all of that work uncached and caching bought close to nothing. Adds theINERTIA_VARY_HEADERSexport naming the set.Varynow lists these names on every Inertia response, where it previously listed onlyX-Inertia, so anything asserting on that exact header value needs updating.once()prop is not cached. On a cache hit the SSR render is skipped entirely, so a cached page costs no render.only: ['auth.user']asks for one field ofauth; sending all ofauthis the payload the partial reload was made to avoid. Prop metadata now names every entry by its full path, so adefer()nested under another prop is advertised where the client will look for it.Server rendering and dev runtime
prepare(page)hook tocreateInertiaSsrApp, which runs once perrender(page)call and hands its result tosetupasprepared. It exists so a request-scoped value can reach the tree without a module-level variable — a Workers isolate serves many requests concurrently, so module-level "current request" state is a cross-request leak. Omit it andpreparedisundefined, which the type now enforces.quarry inertia:devnow keeps the dev server alive, with a default threshold of 900 MB configurable through--heap-limit=<MB>. Supervision runs on macOS and Linux; elsewhere it is disabled with a warning.renderToStringandrenderToStaticMarkupare not available in the worker.ReferenceError: require is not definedormodule is not definedunder the Workers dev and SSR runtime. React 19's server entry,react-dom/client, the ORM data layer and the email renderer all reach CommonJS through packages excluded from Vite's optimizer, so their conditionalrequirereached the worker runtime unconverted. An app that happened to importreact-domelsewhere was unaffected, while a minimal app failed on every request.createPoolFactory is not a functionunder a linked or portal checkout.DocumentRendererService, which renders a builtPageinto an HTML document and owns the single decision between streaming SSR and a client-only shell.InertiaServiceand@stratal/inertia-modalboth delegate to it, so anything rendering an Inertia document outside those paths should inject the token rather than duplicate the branch.Fixes
versionturned every stale client into a server error rather than the reload the check exists to trigger.locationevent now reportsversionChange: true, and async visits are left alone instead of reloading the page underneath a background request; both were previously unreachable.inertia:typesreadsharedDataandaccessControlout ofsrc/app.module.tsalone, and only as a literal, so an app composing its modules elsewhere or passingconfig.asProvider()had every shared prop reach pages as{}and access control never resolve. Both are now read wherever the registration lives, and a provider argument is followed back to the factory it came from.ctx.modal()calls in the type generator the same way asctx.inertia(). If you hand-wrote prop types for a modal page, remove them and let the generated type be the only source.ctx.share()calls were not detected at all, and shared props wrapped inalways(),defer(),optional(),merge()oronce()were typed as the wrapper instead of the value it resolves to.MessageKeysfromstratal/i18n.SeoService.contributed(), which reports whether anything has calledctx.seo()on this request — what a caller rendering one page over another needs in order to keep the underlying page's metadata instead of overwriting it with the defaults.InertiaService.resolveProps()andpartialRequestFor(), so a caller assembling its own page can resolve props with the same semanticsrender()applies.Breaking Changes
ssr.disabledis removed fromInertiaModule.forRoot({ ssr }). Replace it with the Vite plugin'sssrExclude, which both skips SSR and drops the excluded pages from the worker bundle:stratalInertia({ ssrExclude: ['Admin/**'] }).ctx.withoutSsr()and thewithoutSsrcontext variable are removed. SSR exclusion is now build-time and declarative, so there is no per-request runtime opt-out.Varynow lists every Inertia protocol header on every response, not justX-Inertia. Update anything asserting on that exact value.zod/mini. Thezre-export is gone from the validation surface this package re-exports. Import schema builders directly fromzod/miniusing named imports and replace classic chaining with the functional API:z.string().min(1).optional()becomesoptional(string().check(minLength(1))).OpenAPIService.getSpec()becomesgetSpec(container)and is async, androuteFilteris now a metadata predicate(route: RouteSchemaMeta) => booleaninstead of(path, pathItem).Patch Changes
@stratal/[email protected]
Minor Changes
a753e55: Stack modal routes above one another with the stack held by the browser, server-render modal levels, and make the prop helpers work inside
ctx.modal().Nested stacks
basenames the modal route below it; otherwise it starts a fresh stack. Each level owns its URL — path and query string — so a direct visit or refresh renders the whole chain, and closing a level lands where it was opened from, with the query it was opened under.<ModalLink>opens a sheet and carries the visit options that keep the page behind it in place.useModal()gives youmodal,depth,isTop,close(),closeAll(),refresh(),reload()andvisit(), and browser Back closes exactly one level.refresh()takesrouter.get's options,reload()takesrouter.reload's, andclose()/closeAll()takerouter.visit's, so a caller can time the visit it started rather than the next one to finish. All are properties holding closures, so destructuring one is safe.refresh(query)re-reads the open level under a refined query — applying a filter, a sort, or a code the server prices. The level is recognised by its own URL, so a refinement never reads as a second sheet of the same route opening.isModalBackground(ctx)tells a route it is being rendered as the page beneath a modal, so a route that answers clients with a redirect can render instead. Without it that redirect is followed back to the modal and surfaces asModalBaseCycleError, which is added here and thrown when abasechain leads back to a route already in it. A client cannot makeisModalBackgroundanswer true.Closing a level
Referer, so the two could previously aim at each other and no amount of closing ever reached the page.Prop helpers, scroll and SSR
defer,merge,onceandscrollnow work insidectx.modal(). A level was previously built from the raw argument and never run through prop resolution, so none of them had any effect; all four now behave in a sheet exactly as they do on a page.<Deferred>and<InfiniteScroll>re-exported from@stratal/inertia-modal/reactresolvedataagainst the modal they render in, so the same JSX works in a sheet and on a page and no caller composes the wire path itself.A partial reload that names nothing about the level leaves it alone, so the client keeps what it holds and the level's outstanding
defer()props are not advertised a second time — re-announcing them made the client fetch each one again for every reload the surrounding page made. A partial reload naming a level's props resolves just those.Modal levels now server-render.
<Modal />previously initialised its stack as empty state and filled it in two effects, so no level ever appeared in server-rendered HTML; levels now resolve before the tree renders and reach first paint. The open stack is also held outside the page component, so a background-page remount no longer destroys every open sheet.Pass
createInertiaApp'sresolvethroughwithModals()in both the client and SSR entries. There is no bootstrap step to run before hydrating and no provider to wrap the tree in.This makes first-paint modal content possible; it does not make every modal's markup appear. A level whose content renders inside a Radix
Portalstill will not appear in server HTML, sincecreatePortalis an inherently client-side DOM operation. If you want a level's content in first paint, that level has to render without a Portal.Render a modal route's background page client-only when that page is excluded from SSR through
ssrExclude. A direct visit or refresh of such a modal route previously failed withPage not foundand a 500.Keep a sheet on screen while the level replacing it loads, rather than leaving the screen with no sheet for as long as the component takes to arrive.
Testing
Import
@stratal/inertia-modal/testingalongside@stratal/inertia/testing. Eight assertions, chainable like the Inertia family, plus two readers:assertModal(callback?)/assertNoModal(),assertModalComponent(component, depth?),assertModalComponents(components),assertModalCount(count),assertModalDepth(depth),assertModalProp(path, expected, depth?),assertModalOnly(), plusmodalLevel<TProps>(depth?)andmodalLevels<TProps>().assertModalBase()andassertModalClose()assert what a level sits over and where closing it lands — a wrong close target is otherwise invisible until someone taps Close and does not arrive.1.resetModalState()empties everything the package holds outside the React tree, so a test runner sharing the module across tests does not carry one test's open sheet into the next.modalPropPath(prop)is exported for a test naming a level's prop path.Fixes
ctx.seo()on a modal route now reaches the page. A level's metadata is written to the prop the client head-sync reads and injected into the document head on a direct visit, so the sheet's own title and description apply while its URL is the address. It was previously discarded outright, and a direct visit rendered no SEO tags at all. A level that never callsctx.seo()leaves the background page's metadata untouched.<InfiniteScroll>inside a level requesting the same page over and over without adding rows, and silently stopping after a sheet opened over it is closed. A scroll fetch is now recognised as the level it came from asking for the next page of its own prop, and a scrolled level keeps the URL it was opened under so neither the close target nor a later refresh drifts a page at a time.<InfiniteScroll>no longer throws about a missing scroll prop. The subscription now ends with the level, however the level was left — closing it, the back button, a link elsewhere — and a level still open keeps the rows it had loaded.defer()prop of that page — was answered with the level attached and carrying no props, and the client seated that empty level back on the chain, so the sheet lost every prop it held and a level reading one as it renders went blank with an uncaughtTypeError.basechain that cycles — now read as English sentences rather than raw message keys. Themodal.*keys are exported, so an app running i18n can translate or override any of them. Abaseanswering2xxwith a body that is not a page reports a 502 carrying the parse failure as itscause.@stratal/inertia-modal/reactentry, so pages hydrate in development. The entry reached request handling that runs onnode:async_hooks; a production build dropped it as unused, but a development build shipped it to the browser where it cannot resolve, so no page renderingModalhydrated.Breaking Changes
ctx.inertiaModal(component, props, { baseURL })is replaced byctx.modal(component, props, { base }). Rename the call and the option at every call site, including modal routes whose background is another modal route — there is no separate call for those.MODAL_VISITandMODAL_REFRESHare removed. Replace<Link {...MODAL_VISIT}>with<ModalLink>, and aMODAL_REFRESHvisit withrefresh()fromuseModal().useModal().redirect()is renamed toclose(). Updateconst { redirect } = useModal()toconst { close } = useModal(), and anyonClick={redirect}toonClick={close}.useModal()no longer returnsshoworprops. Readmodalinstead: it isundefinedoutside a modal, and carries the level'sprops.useModalPropPathis removed. A level's props sit at a fixed path, soreload({ only: ['items'] })fromuseModal()names them by bare name.prepareModalComponents,rememberModalComponents,clearModalComponentsandModalComponentsContextare removed. PassresolvethroughwithModals()instead. A test suite that calledclearModalComponentsbetween tests wantsresetModalState().modalPropPathmoves to@stratal/inertia-modal/testingand takes only a prop name.ModalNestingLimitError,ModalPayloadMismatchErrorandModalRequestHeaderErrorare removed, along with every modal request header. Nothing about the stack travels to the server any more.A response carries one modal, at
page.props.modal, addressed atmodal.props.<name>with no key in the path; the keyed and positional containers are both gone, andModalData.nativeBackwith them. No runtime code outside the package reads the payload, so this affects only tests asserting on it directly — move those onto the assertions above rather than onto the new field names:Modal levels now server-render, where they were previously always drawn in after hydration. A consumer relying on client-only mounting inside a level — a
useLayoutEffectthat assumed it would never run on the server, say — should read this as a behaviour change, not a fix.Patch Changes
@stratal/[email protected]
Minor Changes
a753e55: Give each test file its own leased database, drain deferred work before a test finishes, and supply the cache and gateway bindings the runtime never populates.
Database isolation
<base>_w_<slot>, and gets a fresh clone of the migrated template in it; the slot frees when the pool disposes the file's isolate. A run therefore holds at most as many databases as it runs files at once, so a long suite no longer piles up one database per file until Postgres runs out of disk mid-run.truncateDbor the reset engine.createTestDatabaseGlobalSetupaccepts a one-timepreparehook to bake expensive baseline state — seed data, a default tenant schema — into the template once, so every file's database inherits it through the clone instead of rebuilding it per test.createTestDatabaseGlobalSetupnow returns a teardown, so a run reclaims what it created instead of leaving the leftovers for the next run's setup to find. Consumers pass it toglobalSetupexactly as before and need no change. The sweep is connection-guarded and skips a slot that is currently leased, so a database cloned but not yet connected to is never dropped by a concurrent process.truncateDb(name?, opts?)accepts aResetOptionspreserve-list; migration tables matching_prisma%are always preserved.hookTimeout,fileParallelismorisolateon the consuming project is now respected instead of being overwritten.Bindings and lifecycle
ctx.cachebinding so cache-decorated routes are testable with no configuration. Neither Miniflare nor workerd populates it, so without this a single@Cacheableor@PurgesCacheroute would fail an app's entire suite on the first request.Test.createTestingModule()installs a stub by default:@Cacheableroutes return realCache-ControlandCache-Tagheaders, and purges succeed, recording eachPurgeSpecin call order onmodule.cache.purges. Passcache: falseto opt back into the unconfigured runtime.ctx.exportsstub by default so adopting the response-cache gateway does not break existing suites. Assert forwarded requests and their resolved partitions throughmodule.gateway.loopbacks. The stub answers to any export name, so a passing suite is not what proves your configured entrypoint is correct — the type check against your Worker's exports is.ctx.waitUntilbeforefetch()resolves, mirroring the Workers runtime. A non-blocking listener's deferred database write previously stayed in flight past the response and could still be running at the next request or at teardown, where disposing that resource hung the suite past the hook timeout.close()before tearing the app down.fetch()already drained per call, but the websocket, SSE and Quarry helpers share the same queue, so a suite using only those could reach teardown with writes still in flight and race the connection pool's disposal.Testing surface
Test.createTestingModule()acceptstrailingSlashandversioningand passes both to theApplicationit builds, taking the same shapes as on theStratalconstructor. A testing module never runs the app's entry file, so an app configuring either there previously had it in production only — its suite asserted URL shapes that configuration would never emit. Both stay unset by default.head(),list()anddeleteMany()in fake storage, so the new storage methods are exercisable without R2.list()returns one object per page unless alimitis passed, so a caller that ignorescursorfails in tests instead of undercounting against a real bucket, andcontentTypeandmetadataare omitted unlessincludeMetadata: true, matching what R2 returns.module.inertiasending a hard-codedX-Inertia-Version. It sent'1', so any app configuring a real asset version had every request read as a stale client and answered with a 409 — an entire Inertia suite failing on a value the tests never chose. A test that wants the mismatch path can ask for it withmodule.inertia.withHeaders({ 'X-Inertia-Version': 'stale' }).ReadableStream is disturbedwhen the body is a single-use stream, which is the shape a chunked upload delivers.stratalTest()typed against a single Vite instance. Vitest and@stratal/inertiaresolved two different copies, which surfaced as aPluginthat would not assign toPlugin, an "excessive stack depth" comparison, and a missingtestkey onUserConfig.Breaking Changes
sharedanddatabaseisolation toggle is gone, along with theisolationoption on bothstratalTest({ database })andcreateTestDatabaseGlobalSetup. PassstratalTest({ database: {} })to enable isolation and delete anyisolation:option.stratalTest({ database })now requiresisolate: trueand throws onisolate: false.createTestDatabaseGlobalSetupnow requiresschema. Add it if you were relying on the previous default.@cloudflare/vitest-pool-workersis now@cloudflare/vitest-plugin. Update the dependency, any direct import of it, and thetypesentry in your testtsconfig.json.npx @cloudflare/codemods vitest:pool-workers-to-vitest-plugindoes all three. A config that only callsstratalTest()needs no change beyond the dependency.stratalTest()and its options are unchanged, and the integration supports Vitest 4.1 and later.Patch Changes
@stratal/[email protected]
Patch Changes
a753e55: Release alongside the rest of the packages; nothing changed in this one.
Every Stratal package is versioned as one fixed group, so
@stratal/feature-flagsis republished at the same version as the packages it builds on rather than being left behind. Its peer ranges are open-ended, so an existing install keeps resolving — upgrade only to keep one aligned set of versions across the framework.Updated dependencies [a753e55]
Updated dependencies [a753e55]