Jay-Stack Environment Query Parameter Handling
Design Log #53: Jay Stack Environment Query Parameter Handling
Written for AI agents. See Log Methodology Note below for details.
Problem Statement
The @jay-framework/compiler-jay-stack plugin adds ?jay-client and ?jay-server query parameters to local imports to propagate the transformation environment through the import chain. However, this breaks the jay-plugin (jay:runtime) because:
Contract file detection fails: Files like
mood-tracker.jay-contract?jay-clientare not recognized as contract files becausehasExtension(source, JAY_CONTRACT_EXTENSION)looks for.jay-contractat the end.Jay-html file detection fails: Same issue with
.jay-htmlfiles.Potential collision with sandbox parameters: The
jay-pluginalready uses similar query parameters (?jay-main-sandbox,?jay-worker-trusted,?jay-worker-sandbox) for security transformations. These could collide or interfere with the new?jay-client/?jay-serverparameters.
Example of the Problem
// In mood-tracker-plugin/lib/index.ts
import { MoodTrackerContract } from './mood-tracker.jay-contract';
// After jay-stack-compiler transformation:
import { MoodTrackerContract } from './mood-tracker.jay-contract?jay-client';
// jay-plugin sees: 'mood-tracker.jay-contract?jay-client'
// jay-plugin checks: hasExtension(source, '.jay-contract') → FALSE ❌
// Contract file is NOT processed!
Options Analysis
Option 1: Post-Processing Plugin to Strip Query Parameters
Add a third plugin that runs after jay-stack:code-split but before jay:runtime to strip the ?jay-client/?jay-server suffixes.
Implementation:
export function jayStackCompiler(jayOptions: JayRollupConfig = {}): Plugin[] {
return [
{
name: 'jay-stack:code-split',
enforce: 'pre',
transform(code, id) {
/* ... add ?jay-client/server to imports */
},
},
{
name: 'jay-stack:strip-query-params',
enforce: 'pre',
transform(code, id) {
// Strip ?jay-client and ?jay-server from import paths
// before jay:runtime sees them
return code.replace(/\?jay-client/g, '').replace(/\?jay-server/g, '');
},
},
jayRuntime(jayOptions),
];
}
Pros:
- ✅ Simple to implement
- ✅ Minimal changes to existing code
- ✅ Quick fix for the immediate problem
Cons:
- ❌ Feels like a workaround, not a proper solution
- ❌ Plugin chain becomes more complex (3 plugins instead of 2)
- ❌ Fragile - relies on plugin ordering
- ❌ Doesn't solve the underlying architectural issue
- ❌ Code does a lot of work adding query params just to remove them immediately
- ❌ The
resolveIdhook still sees the query params - onlytransformis fixed
Option 2: Universal Utility for Environment Metadata on Imports
Create a shared utility in @jay-framework/compiler-shared that:
- Provides a consistent API for adding/removing/detecting environment query parameters
- Works for both
?jay-client/?jay-serverAND?jay-main-sandbox/?jay-worker-* - Is used by both
jay-stack-compilerandjay-plugin
Implementation:
// In @jay-framework/compiler-shared
export enum JayEnvironment {
Client = 'client',
Server = 'server',
MainSandbox = 'main-sandbox',
WorkerTrusted = 'worker-trusted',
WorkerSandbox = 'worker-sandbox',
}
export const JAY_QUERY_PREFIX = '?jay-';
/**
* Parse a module specifier to extract the base path and any jay environment
*/
export function parseJayModuleSpecifier(specifier: string): {
basePath: string;
environment?: JayEnvironment;
queryParams: string;
} {
const queryIndex = specifier.indexOf('?');
if (queryIndex === -1) {
return { basePath: specifier, queryParams: '' };
}
const basePath = specifier.substring(0, queryIndex);
const queryString = specifier.substring(queryIndex);
// Extract jay environment from query
for (const env of Object.values(JayEnvironment)) {
const jayQuery = `${JAY_QUERY_PREFIX}${env}`;
if (queryString.includes(jayQuery)) {
// Remove jay query from remaining params
const remainingParams = queryString
.replace(jayQuery, '')
.replace(/^\?&/, '?')
.replace(/&$/, '')
.replace(/^&/, '');
return {
basePath,
environment: env,
queryParams: remainingParams || '',
};
}
}
return { basePath, queryParams: queryString };
}
/**
* Add jay environment to a module specifier
*/
export function addJayEnvironment(specifier: string, environment: JayEnvironment): string {
const { basePath, queryParams } = parseJayModuleSpecifier(specifier);
const jayQuery = `${JAY_QUERY_PREFIX}${environment}`;
if (queryParams) {
return `${basePath}${jayQuery}&${queryParams.substring(1)}`;
}
return `${basePath}${jayQuery}`;
}
/**
* Check if a module specifier has a jay extension (ignoring query params)
*/
export function hasJayExtension(specifier: string, extension: string): boolean {
const { basePath } = parseJayModuleSpecifier(specifier);
return basePath.endsWith(extension);
}
Updated jay-plugin usage:
// In rollup-plugin/lib/runtime/runtime-compiler.ts
async resolveId(source, importer, options) {
// Use the new utility to check extensions
if (hasJayExtension(source, JAY_EXTENSION)) {
return await resolveJayHtml(this, source, importer, options, ...);
}
if (hasJayExtension(source, JAY_CONTRACT_EXTENSION)) {
return await resolveJayContract(this, source, importer, options);
}
// ... etc
}
Pros:
- ✅ Clean architecture - single source of truth
- ✅ Both plugins use the same logic
- ✅ Extensible for future environments
- ✅ Properly handles all edge cases
- ✅ Makes extension detection work correctly with any query params
- ✅ Can combine multiple environments if needed (e.g.,
?jay-client&jay-worker-trusted)
Cons:
- ❌ More upfront work
- ❌ Requires changes to both plugins
- ❌ Need to update all places that check for extensions
Option 3: Hybrid - Exclude Certain Files from Query Param Addition
Instead of adding query params to ALL local imports, be selective:
function shouldAddQueryParam(modulePath: string): boolean {
// Don't add query params to contract files - they're just types
if (modulePath.endsWith('.jay-contract')) return false;
// Don't add to jay-html files - they're templates
if (modulePath.endsWith('.jay-html') || modulePath.endsWith('.jay')) return false;
// Add to everything else
return true;
}
Pros:
- ✅ Quick to implement
- ✅ Minimal changes
- ✅ Contract files work correctly
Cons:
- ❌ Inconsistent - some imports get query params, some don't
- ❌ If a contract file imports another local file, that file won't get the query param
- ❌ Doesn't solve the collision issue with sandbox params
- ❌ Requires maintaining a list of exceptions
Recommendation
Option 2: Universal Utility is the recommended approach.
Rationale
Architectural Consistency: Both the security sandbox transformations and the client/server code splitting are fundamentally the same pattern - adding environment metadata to imports. They should use the same infrastructure.
Future-Proof: As Jay evolves, there may be more environment dimensions (e.g., SSR vs CSR, development vs production). A universal utility makes it easy to add these.
Correctness: The current
hasExtension()function is brittle - it doesn't handle query parameters at all. This bug exists independently of our changes and should be fixed.Composability: With a proper utility, you could have a file that's both
?jay-clientand?jay-worker-trusted- the environments are orthogonal.Maintainability: One place to understand and modify, rather than scattered string manipulation.
Implementation Plan
Phase 1: Create Shared Utility
- Add
parseJayModuleSpecifier(),addJayEnvironment(),hasJayExtension()tocompiler-shared - Add unit tests for the utility
Phase 2: Update jay-plugin
- Replace direct
hasExtension()calls withhasJayExtension()for jay-specific extensions - Update
resolveIdto use the new parsing - Ensure existing sandbox query params still work
Phase 3: Update jay-stack-compiler
- Use
addJayEnvironment()instead of string concatenation - Remove hardcoded
?jay-client/?jay-serverstrings
Phase 4: Testing
- Test mood-tracker-plugin build
- Test contract file imports with query params
- Test combination of client/server + sandbox environments
Alternative Consideration
If Option 2 is too much work for immediate needs, Option 3 (Hybrid) could be a quick interim solution. Contract files are type-only imports that don't contain runtime code, so they don't need the environment query param. However, this should be considered technical debt to be addressed later.
Open Questions
Should contract files get environment query params at all? They're type-only - the answer might be "no" regardless of the solution we choose.
How do we handle the case where both sandbox AND client/server params are needed? Option 2 handles this naturally; other options might struggle.
Should we consolidate all environment-related query params into a single query param format? e.g.,
?jay-env=client,worker-trustedinstead of multiple?jay-*params.
Status: ✅ Implemented (Option 2)
Implementation Summary
Changes Made
1. New Shared Utilities (compiler-shared/lib/jay-module-specifier.ts):
JayBuildEnvironmentenum:Client,ServerparseJayModuleSpecifier(): Parses module specifiers to separate base path from query paramsaddBuildEnvironment(): Adds?jay-clientor?jay-serverto a module pathhasJayExtension(): Query-param-aware extension detection (replaceshasExtensionfor Jay files)getBasePath(): Gets the base path without query parametersisLocalModule(): Checks if a path is a local file (relative path)hasBuildEnvironment(): Checks if a module has a build environment query param
2. Updated Jay Plugin (rollup-plugin):
resolveId: UseshasJayExtension()for detecting.jay-htmland.jay-contractfilesresolveJayHtml/resolveJayContract: Parse query params and add.tsextension BEFORE the query paramsload: Contract files are now compiled to TypeScript in theloadhook (nottransform)transform: UseshasJayExtension()for file detectiongetFileContext: Strips query params before extracting filename/dirname
3. Updated Jay Stack Compiler (compiler-jay-stack):
- Uses
addBuildEnvironment()instead of manual string concatenation - Uses
isLocalModule()from shared utilities - Uses
hasBuildEnvironment()to check if query param already exists
Key Insight: Contract Compilation in Load Hook
The critical fix was moving contract YAML compilation from transform to load:
Before: load → (esbuild mangles YAML) → transform → compile
After: load → compile to TS → (esbuild sees valid TS) → transform (no-op)
esbuild runs between load and transform hooks, so the contract must be valid TypeScript by the time esbuild sees it.
Test Results
- ✅
compiler-shared: 45 tests passing (including new module specifier tests) - ✅
compiler-jay-stack: 12 tests passing - ✅
mood-tracker-plugin: Builds successfully with dual outputs
Estimated Effort:
- Option 1: ~1 hour
- Option 2: ~4-6 hours (actual: ~3 hours)
- Option 3: ~30 minutes
Recommended: Option 2 for long-term architecture, with Option 3 as a possible interim fix if time is critical.
Critical Fix: Module Identity Preservation
Problem Discovered During Testing
After implementing Option 2, the fake-shop example failed with "Service not found" errors. Root cause: query parameters on imports break module identity.
When page.ts?jay-server imports products-database, Vite treats it as a different module than when jay.init.ts imports products-database. This caused service tokens (symbols) registered in jay.init.ts to be different from those looked up in page components.
Solution: Asymmetric Query Param Strategy
Server builds: No query params → preserves module identity
Client builds: ?jay-client → separate modules (acceptable for client bundles)
Key Changes
compiler-jay-stack/lib/index.ts: Detect environment automatically:?jay-client→ client build (explicit)?jay-server→ server build (backward compat)- SSR mode (
options.ssr === true) → server build (automatic) - No signals → no transformation
compiler-jay-stack/lib/transform-jay-stack-builder.ts: Only propagate?jay-clientto imports for client builds. Server builds leave imports unchanged.stack-server-runtime/lib/load-page-parts.ts: Removed?jay-serverfromssrLoadModule()calls. SSR mode is detected automatically by the transformer.
Why This Works
jay.init.ts imports products-database → /path/products-database (module A)
page.ts (SSR) imports products-database → /path/products-database (module A) ✓ SAME
page.ts?jay-client imports products-database?jay-client → separate module (OK for client)
Server-side code shares module identity, so service tokens work correctly.
Final Simplification: Eliminate Query Parameters Entirely
Discovery
After implementing the asymmetric query param strategy, we discovered that Vite's native build.ssr configuration provides all the environment information we need:
vite build→options.ssr = falsein transform hookvite build --ssr→options.ssr = truein transform hook
This applies to ALL files in the build, not just the entry point.
Simplified Approach
Before (with query parameters):
// vite.config.ts
entry: isSsrBuild
? { index: resolve(__dirname, 'lib/index.ts?jay-server') }
: { 'index.client': resolve(__dirname, 'lib/index.ts?jay-client') };
// Plugin had to detect and propagate ?jay-client/?jay-server
After (using options.ssr):
// vite.config.ts
entry: isSsrBuild
? { index: resolve(__dirname, 'lib/index.ts') }
: { 'index.client': resolve(__dirname, 'lib/index.ts') };
// Plugin uses options.ssr directly
const environment = options?.ssr ? 'server' : 'client';
Benefits
- No query parameter propagation - Environment is global per build
- Simpler configuration - Same entry point for both builds
- Module identity preserved - No query params means no module duplication
- Works for dev server AND package builds - Both use
options.ssr
Code Removed
propagateQueryParamsoption in transformrewriteLocalImport/rewriteLocalExportfunctions?jay-client/?jay-serverdetection in plugin- Query param utilities in
compiler-shared(can be cleaned up if no other uses) - Re-export test fixtures
Test Results After Simplification
- ✅
compiler-jay-stack: 10 tests passing - ✅
mood-tracker-plugin: Builds with code splitting (0.73 kB server, 1.17 kB client) - ✅
wix-stores: Builds with code splitting (9.17 kB server, 2.38 kB client) - ✅
fake-shopdev server: Services initialize correctly, pages render
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.