@statorjs/stator 2.11.0 → 2.12.0

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": "@statorjs/stator",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source, loaded natively by its own toolchain — no build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,6 +35,10 @@
35
35
  "types": "./src/server/index.ts",
36
36
  "default": "./src/server/index.ts"
37
37
  },
38
+ "./server/runtime": {
39
+ "types": "./src/server/runtime.ts",
40
+ "default": "./src/server/runtime.ts"
41
+ },
38
42
  "./machine": {
39
43
  "types": "./src/engine/index.ts",
40
44
  "default": "./src/engine/index.ts"
@@ -101,7 +105,7 @@
101
105
  "pino-pretty": "^13.2.0",
102
106
  "vite": "^6.4.5",
103
107
  "vitest": "^3.2.7",
104
- "@statorjs/stator": "2.11.0"
108
+ "@statorjs/stator": "2.12.0"
105
109
  },
106
110
  "scripts": {
107
111
  "typecheck": "tsc --noEmit",
@@ -109,6 +113,6 @@
109
113
  "test:watch": "vitest",
110
114
  "test:browser": "node browser-tests/tables.mjs",
111
115
  "build:client": "esbuild src/client/runtime.ts --bundle --format=iife --target=es2020 --outfile=dist/client.js",
112
- "build:components": "node scripts/compile-components.mjs && biome check --write src/components/"
116
+ "build:components": "node src/cli/stator.js build:lib src/components && biome check --write src/components/"
113
117
  }
114
118
  }
@@ -12,8 +12,7 @@ export interface CompileComponentResult {
12
12
  }
13
13
 
14
14
  /** Compile one `.stator` file to its sibling `.ts` (+ a sibling `.css` when
15
- * it has scoped styles). Used by both `build.ts` and
16
- * `scripts/compile-components.ts`. */
15
+ * it has scoped styles). Used by both `build.ts` and `compileLibrary`. */
17
16
  export async function compileComponentFile(
18
17
  file: string,
19
18
  baseDir: string,
@@ -0,0 +1,91 @@
1
+ import { readFile, stat, writeFile } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
+ import { compileComponentFile } from './compile-component.ts'
4
+ import { walkFiles } from './islands.ts'
5
+ import { entryCss } from './route-css.ts'
6
+
7
+ export interface CompileLibraryOptions {
8
+ /** Package root. Compiled ids (scope hashes, diagnostics) are relative to it. */
9
+ root: string
10
+ /** Directory holding the `.stator` sources; walked recursively. */
11
+ dir: string
12
+ /** The barrel the published stylesheet is aggregated for. Defaults to
13
+ * `<dir>/index.ts` when it exists; with no barrel every component's CSS is
14
+ * included. */
15
+ entry?: string
16
+ /** Where the aggregated stylesheet is written. Default `<dir>/components.css`. */
17
+ cssOut?: string
18
+ }
19
+
20
+ export interface CompileLibraryResult {
21
+ compiled: number
22
+ islands: number
23
+ cssOut: string
24
+ cssBytes: number
25
+ /** The barrel the stylesheet was aggregated from; undefined when every
26
+ * component's CSS was included. */
27
+ entry?: string
28
+ }
29
+
30
+ /**
31
+ * Compile a component library for publishing. Every `.stator` under `dir`
32
+ * becomes its sibling `.stator.ts` (+ `.css` when styled), `.stator` imports
33
+ * between them are rewritten to the compiled siblings, and one stylesheet is
34
+ * aggregated for the package's `./components.css` export — the same walk a
35
+ * route's CSS uses, rooted at the barrel, so the published file holds exactly
36
+ * what the barrel reaches.
37
+ */
38
+ export async function compileLibrary(opts: CompileLibraryOptions): Promise<CompileLibraryResult> {
39
+ const root = resolve(opts.root)
40
+ const dir = resolve(opts.dir)
41
+ const files = (
42
+ await walkFiles(
43
+ dir,
44
+ (f) => f.endsWith('.stator'),
45
+ (name) => name === 'node_modules' || name === 'dist' || name.startsWith('.'),
46
+ )
47
+ ).sort()
48
+
49
+ let islands = 0
50
+ const compiled: string[] = []
51
+ for (const file of files) {
52
+ const r = await compileComponentFile(file, root)
53
+ if (r.isClient) islands++
54
+ compiled.push(`${file}.ts`)
55
+ if (r.clientFile) compiled.push(r.clientFile)
56
+ }
57
+
58
+ // A component importing a sibling `.stator` must import the compiled module
59
+ // in the published package. Bare specifiers stay as written: a package's
60
+ // exports map resolves them.
61
+ for (const file of compiled) {
62
+ const code = await readFile(file, 'utf8')
63
+ const rewritten = code.replace(/(['"])(\.\.?\/[^'"]+\.stator)\1/g, '$1$2.ts$1')
64
+ if (rewritten !== code) await writeFile(file, rewritten)
65
+ }
66
+
67
+ const entry = opts.entry ? resolve(opts.entry) : await existing(join(dir, 'index.ts'))
68
+ const cssOut = opts.cssOut ? resolve(opts.cssOut) : join(dir, 'components.css')
69
+ let css = ''
70
+ if (entry) {
71
+ css = await entryCss(entry, root)
72
+ } else {
73
+ for (const file of files) {
74
+ const text = await readFile(`${file}.css`, 'utf8').catch(() => undefined)
75
+ if (text) css += `/* ${file.slice(root.length + 1).replace(/\\/g, '/')}.ts */\n${text}\n`
76
+ }
77
+ }
78
+ // Always written, even empty, so the `./components.css` export resolves.
79
+ await writeFile(cssOut, css)
80
+
81
+ return { compiled: files.length, islands, cssOut, cssBytes: css.length, entry }
82
+ }
83
+
84
+ async function existing(path: string): Promise<string | undefined> {
85
+ try {
86
+ await stat(path)
87
+ return path
88
+ } catch {
89
+ return undefined
90
+ }
91
+ }
@@ -7,6 +7,8 @@ export type { ArtifactDeps } from './artifact.ts'
7
7
  export { writeArtifactDeps } from './artifact.ts'
8
8
  export type { BuildConfig, BuildResult, StatorManifest } from './build.ts'
9
9
  export { buildApp } from './build.ts'
10
+ export type { CompileLibraryOptions, CompileLibraryResult } from './compile-library.ts'
11
+ export { compileLibrary } from './compile-library.ts'
10
12
  export type { CopySet, CopySetOptions, UntracedImport } from './copy-set.ts'
11
13
  export { resolveCopySet } from './copy-set.ts'
12
14
  export { loadProductionHead } from './head.ts'
package/src/build/sync.ts CHANGED
@@ -32,6 +32,8 @@ export interface SyncResult {
32
32
  checks: number
33
33
  /** The generated-types directory. */
34
34
  outDir: string
35
+ /** Every `.stator` file the walk found, absolute. */
36
+ files: string[]
35
37
  }
36
38
 
37
39
  const TYPES_DIR = join('.stator', 'types')
@@ -71,7 +73,7 @@ export async function syncTypes(root: string): Promise<SyncResult> {
71
73
  await writeFile(checkTarget, virtual.tsx.code)
72
74
  checks++
73
75
  }
74
- return { written, checks, outDir }
76
+ return { written, checks, outDir, files }
75
77
  }
76
78
 
77
79
  async function walk(dir: string): Promise<string[]> {
@@ -0,0 +1,24 @@
1
+ import { relative, resolve } from 'node:path'
2
+ import { compileLibrary } from '../../build/index.ts'
3
+ import type { CliContext } from '../run.ts'
4
+
5
+ /** Compile a component library for publishing — see `compileLibrary`. The
6
+ * directory is the first positional (default `src`); its `index.ts` is the
7
+ * barrel when present, and the stylesheet lands beside it. */
8
+ export async function run(ctx: CliContext): Promise<void> {
9
+ const dir = resolve(ctx.root, ctx.rest[0] ?? 'src')
10
+ const r = await compileLibrary({ root: ctx.root, dir })
11
+ const rel = (p: string) => relative(ctx.root, p).replace(/\\/g, '/')
12
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
13
+ process.stdout.write(
14
+ `stator build:lib: ${plural(r.compiled, 'component')} under ${rel(dir)}/ → ${rel(r.cssOut)}: ` +
15
+ (r.entry
16
+ ? `${r.cssBytes} bytes reached from ${rel(r.entry)}\n`
17
+ : `${r.cssBytes} bytes, every component (no index.ts barrel)\n`),
18
+ )
19
+ if (r.islands > 0) {
20
+ process.stdout.write(
21
+ ` note: ${plural(r.islands, 'island')} compiled, but a consuming app can't discover islands from a package yet — they stay in the app\n`,
22
+ )
23
+ }
24
+ }
@@ -1,5 +1,9 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { relative } from 'node:path'
1
3
  import ts from 'typescript'
2
4
  import { syncTypes } from '../../build/index.ts'
5
+ import { sourceId } from '../../build/source-id.ts'
6
+ import { CompileError, compile, regionResolverFor } from '../../compiler/index.ts'
3
7
  import type { CliContext } from '../run.ts'
4
8
 
5
9
  /**
@@ -9,12 +13,19 @@ import type { CliContext } from '../run.ts'
9
13
  * to `dist/` unvalidated, so a broken server import shipped silently. `build`
10
14
  * now runs this first.
11
15
  *
12
- * Two steps: (1) `syncTypes` regenerates each `.stator.d.ts` + the virtual TSX
16
+ * Three steps: (1) `syncTypes` regenerates each `.stator.d.ts` + the virtual TSX
13
17
  * the language server typechecks, so the app's `tsconfig` sees current types;
14
- * (2) a `tsc --noEmit`-equivalent pass via the TypeScript API over that config.
18
+ * (2) every `.stator` file goes through the real compiler — the same call `dev`
19
+ * makes on import and the editor makes per edit, so all three reject the same
20
+ * inputs; (3) a `tsc --noEmit`-equivalent pass via the TypeScript API.
15
21
  */
16
22
  export async function checkStack(root: string): Promise<void> {
17
- const { written } = await syncTypes(root)
23
+ const { written, files } = await syncTypes(root)
24
+
25
+ const compileErrors = await compileAll(root, files)
26
+ if (compileErrors.length > 0) {
27
+ process.stderr.write(`${compileErrors.join('\n\n')}\n\n`)
28
+ }
18
29
 
19
30
  const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json')
20
31
  if (!configPath) throw new Error(`no tsconfig.json found at or above ${root}`)
@@ -45,7 +56,9 @@ export async function checkStack(root: string): Promise<void> {
45
56
  getNewLine: () => ts.sys.newLine,
46
57
  }),
47
58
  )
48
- const n = diagnostics.length
59
+ }
60
+ const n = compileErrors.length + diagnostics.length
61
+ if (n > 0) {
49
62
  throw new Error(`typecheck failed with ${n} error${n === 1 ? '' : 's'}`)
50
63
  }
51
64
 
@@ -67,6 +80,29 @@ export async function checkStack(root: string): Promise<void> {
67
80
  )
68
81
  }
69
82
 
83
+ /** Compile every `.stator` file, keeping only the errors — output is
84
+ * discarded. Formatted like tsc's diagnostics (`file:line:col - error`) with
85
+ * the compiler's own code frame. */
86
+ async function compileAll(root: string, files: string[]): Promise<string[]> {
87
+ const errors: string[] = []
88
+ for (const file of files) {
89
+ const source = await readFile(file, 'utf8')
90
+ const { id, kind } = sourceId(root, file)
91
+ try {
92
+ compile(source, { id, kind, resolveRegions: regionResolverFor(file, source) })
93
+ } catch (e) {
94
+ if (!(e instanceof CompileError)) throw e
95
+ const rel = relative(root, file).replace(/\\/g, '/')
96
+ errors.push(
97
+ e.loc
98
+ ? `${rel}:${e.loc.line}:${e.loc.column} - error: ${e.message}\n\n${e.loc.frame}`
99
+ : `${rel} - error: ${e.message}`,
100
+ )
101
+ }
102
+ }
103
+ return errors
104
+ }
105
+
70
106
  export async function run(ctx: CliContext): Promise<void> {
71
107
  await checkStack(ctx.root)
72
108
  }
package/src/cli/run.ts CHANGED
@@ -11,7 +11,7 @@ export interface CliContext {
11
11
  rest: string[]
12
12
  }
13
13
 
14
- const COMMANDS = ['dev', 'build', 'start', 'check', 'test'] as const
14
+ const COMMANDS = ['dev', 'build', 'start', 'check', 'test', 'build:lib'] as const
15
15
  type Command = (typeof COMMANDS)[number]
16
16
 
17
17
  const HELP = `stator — the Stator toolchain CLI
@@ -24,6 +24,7 @@ Commands:
24
24
  start Serve a built dist/ in production mode
25
25
  check Typecheck the whole stack (server + generated .stator types), no output
26
26
  test Run the test suite
27
+ build:lib [dir] Compile a component library for publishing (default dir: src)
27
28
 
28
29
  Options:
29
30
  --root <dir> App root (default: current directory)
@@ -66,7 +67,7 @@ const { loadDotenv } = await import('../server/env.ts')
66
67
  loadDotenv(ctx.root)
67
68
 
68
69
  // Dynamic import per command so `check` never loads the dev server's watcher, etc.
69
- const { run } = (await import(`./commands/${command}.ts`)) as {
70
+ const { run } = (await import(`./commands/${command.replace(':', '-')}.ts`)) as {
70
71
  run: (ctx: CliContext) => Promise<void>
71
72
  }
72
73
 
@@ -7,7 +7,7 @@ import {
7
7
  isCustomElementTag,
8
8
  pascalToKebab,
9
9
  } from './client-script.ts'
10
- import { CompileError, type DiagnosticLocation, locAt } from './diagnostics.ts'
10
+ import { CompileError, type DiagnosticLocation, locAt, parseDiagnosticsOf } from './diagnostics.ts'
11
11
  import {
12
12
  ISLAND_SHELL_EXTRAS,
13
13
  importLine,
@@ -109,6 +109,7 @@ export function compile(source: string, opts: CompileOptions = {}): CompileResul
109
109
  file: opts.id,
110
110
  fm,
111
111
  source,
112
+ templateOffset,
112
113
  })
113
114
  }
114
115
  throw new CompileError(
@@ -171,10 +172,12 @@ function compileClient(
171
172
  fm?: FrontmatterParts
172
173
  /** Original source, for located collision diagnostics. */
173
174
  source?: string
175
+ /** Offset of the template body in `source`. */
176
+ templateOffset?: number
174
177
  },
175
178
  ): CompileResult {
176
179
  const classes = analyzeScriptClasses(script)
177
- const root = extractClientRoot(template, ctx.file)
180
+ const root = extractClientRoot(template, ctx)
178
181
 
179
182
  // Name-match validation (both directions, hyphen rule).
180
183
  const tags = new Set([root.tag])
@@ -271,20 +274,32 @@ function compileClient(
271
274
  * split-and-reassemble so a component can style/flag its own root; the use site
272
275
  * merges them under its props (FINDINGS #4). Namespaced directives (class:list,
273
276
  * on:) and expression-valued attrs are not static and are skipped here. */
277
+ /** The island root's own attributes: static only. The shell renders the root
278
+ * from the use site's props, so a dynamic value here has nothing to bind to —
279
+ * rather than silently dropping it, say so. */
274
280
  function staticRootAttrs(
281
+ tag: string,
275
282
  attrs: ts.JsxAttributes,
276
283
  sf: ts.SourceFile,
284
+ at: (node: ts.Node) => DiagnosticLocation | undefined,
277
285
  ): Record<string, string | boolean> {
278
286
  const out: Record<string, string | boolean> = {}
279
287
  for (const attr of attrs.properties) {
280
- if (!ts.isJsxAttribute(attr) || ts.isJsxNamespacedName(attr.name)) continue
281
- const name = attr.name.getText(sf)
282
- const init = attr.initializer
283
- if (init === undefined) {
284
- out[name] = true // valueless boolean attribute
285
- } else if (ts.isStringLiteral(init)) {
286
- out[name] = init.text
288
+ const isStatic =
289
+ ts.isJsxAttribute(attr) &&
290
+ !ts.isJsxNamespacedName(attr.name) &&
291
+ (attr.initializer === undefined || ts.isStringLiteral(attr.initializer))
292
+ if (!isStatic) {
293
+ throw new CompileError(
294
+ `stator: <${tag} ${attr.getText(sf)}> — attributes on an island's root must be static ` +
295
+ `(a string literal or a bare boolean). The root is rendered by the shell from the use ` +
296
+ `site's props; a dynamic value belongs on an inner element, or declare it with ` +
297
+ `\`static attrs\` and pass it from the use site.`,
298
+ at(attr),
299
+ )
287
300
  }
301
+ const name = attr.name.getText(sf)
302
+ out[name] = attr.initializer === undefined ? true : (attr.initializer as ts.StringLiteral).text
288
303
  }
289
304
  return out
290
305
  }
@@ -294,10 +309,16 @@ function staticRootAttrs(
294
309
  * must be the custom element". */
295
310
  function extractClientRoot(
296
311
  template: string,
297
- file?: string,
312
+ ctx: { file?: string; source?: string; templateOffset?: number },
298
313
  ): { tag: string; inner: string; rootAttrs: Record<string, string | boolean> } {
299
- const wrapped = `const __t = (<>${template}</>);`
314
+ const { file } = ctx
315
+ const prefix = 'const __t = (<>'
316
+ const wrapped = `${prefix}${template}</>);`
300
317
  const sf = ts.createSourceFile('t.tsx', wrapped, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
318
+ const at = (node: ts.Node): DiagnosticLocation | undefined =>
319
+ ctx.source != null && ctx.templateOffset != null
320
+ ? locAt(ctx.source, ctx.templateOffset + (node.getStart(sf) - prefix.length), file)
321
+ : undefined
301
322
  let fragment: ts.JsxFragment | undefined
302
323
  const find = (n: ts.Node): void => {
303
324
  if (ts.isJsxFragment(n)) {
@@ -320,13 +341,13 @@ function extractClientRoot(
320
341
  if (ts.isJsxSelfClosingElement(rootEl)) {
321
342
  const tag = rootEl.tagName.getText(sf)
322
343
  requireCustomRoot(tag, file)
323
- return { tag, inner: '', rootAttrs: staticRootAttrs(rootEl.attributes, sf) }
344
+ return { tag, inner: '', rootAttrs: staticRootAttrs(tag, rootEl.attributes, sf, at) }
324
345
  }
325
346
  const el = rootEl as ts.JsxElement
326
347
  const tag = el.openingElement.tagName.getText(sf)
327
348
  requireCustomRoot(tag, file)
328
349
  const inner = wrapped.slice(el.openingElement.getEnd(), el.closingElement.getStart())
329
- return { tag, inner, rootAttrs: staticRootAttrs(el.openingElement.attributes, sf) }
350
+ return { tag, inner, rootAttrs: staticRootAttrs(tag, el.openingElement.attributes, sf, at) }
330
351
  }
331
352
 
332
353
  function requireCustomRoot(tag: string, file?: string): void {
@@ -379,6 +400,29 @@ function processFrontmatter(
379
400
  const locInFm = (node: ts.Node): DiagnosticLocation =>
380
401
  locAt(source, fmOffset(source) + node.getStart(sf), file)
381
402
 
403
+ // The fence is plain TypeScript (ScriptKind.TS): a bare `<p>` lexes as an
404
+ // old-style type assertion, so JSX here used to surface only as esbuild's
405
+ // `Expected ")" but found "return"` on the emitted module — while the
406
+ // editor's TSX model accepted it. Report syntax errors here, with the one
407
+ // targeted hint for that shape.
408
+ const syntax = parseDiagnosticsOf(sf)
409
+ if (syntax.length > 0) {
410
+ const jsx = findJsxLikeAssertion(sf)
411
+ if (jsx) {
412
+ throw new CompileError(
413
+ "stator: JSX isn't allowed in frontmatter — it's plain TypeScript. Markup belongs in " +
414
+ 'the template (use when()/match() for conditional content); a different page for a ' +
415
+ 'not-found case cascades to routes/404.stator.',
416
+ locInFm(jsx),
417
+ )
418
+ }
419
+ const d = syntax[0]!
420
+ throw new CompileError(
421
+ `stator: frontmatter syntax error — ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`,
422
+ locAt(source, fmOffset(source) + d.start, file),
423
+ )
424
+ }
425
+
382
426
  for (const stmt of sf.statements) {
383
427
  if (
384
428
  ts.isImportDeclaration(stmt) ||
@@ -598,6 +642,28 @@ function requireRoute(
598
642
 
599
643
  /** Character offset of the frontmatter body in the original source (after the
600
644
  * opening `---\n`). 4 = `---\n`; good enough for diagnostics line/col. */
645
+ /** The node a `<p>` or `<NotFound>` becomes when parsed as TS: a type assertion
646
+ * whose type is a bare identifier reference. A generic arrow (`<T>(a: T) => a`)
647
+ * is not one, so it stays legal. */
648
+ function findJsxLikeAssertion(sf: ts.SourceFile): ts.TypeAssertion | undefined {
649
+ let found: ts.TypeAssertion | undefined
650
+ const visit = (n: ts.Node): void => {
651
+ if (found) return
652
+ if (
653
+ ts.isTypeAssertionExpression(n) &&
654
+ ts.isTypeReferenceNode(n.type) &&
655
+ ts.isIdentifier(n.type.typeName) &&
656
+ !n.type.typeArguments
657
+ ) {
658
+ found = n
659
+ return
660
+ }
661
+ ts.forEachChild(n, visit)
662
+ }
663
+ visit(sf)
664
+ return found
665
+ }
666
+
601
667
  function fmOffset(source: string): number {
602
668
  return source.startsWith('---') ? source.indexOf('\n') + 1 : 0
603
669
  }
@@ -1,3 +1,5 @@
1
+ import type ts from 'typescript'
2
+
1
3
  /**
2
4
  * Compiler diagnostics: a located `CompileError` plus helpers to turn a
3
5
  * character offset in the original `.stator` source into a line/column + code
@@ -58,6 +60,17 @@ export function codeFrame(source: string, line: number, column: number): string
58
60
  return out.join('\n')
59
61
  }
60
62
 
63
+ /** TypeScript's syntactic diagnostics for a parsed file. `parseDiagnostics` is
64
+ * not in the public typings, but it is what every tool reads and the only
65
+ * way to get them without re-parsing; this is the one place to change if it
66
+ * ever moves. */
67
+ export function parseDiagnosticsOf(sf: ts.SourceFile): readonly ts.DiagnosticWithLocation[] {
68
+ const { parseDiagnostics } = sf as unknown as {
69
+ parseDiagnostics?: readonly ts.DiagnosticWithLocation[]
70
+ }
71
+ return parseDiagnostics ?? []
72
+ }
73
+
61
74
  /** Build a `DiagnosticLocation` from a character offset in the original source. */
62
75
  export function locAt(source: string, offset: number, file?: string): DiagnosticLocation {
63
76
  const { line, column } = offsetToLineCol(source, offset)
@@ -319,6 +319,55 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
319
319
  }
320
320
  checkNoBindingInRcdata(fragment, null)
321
321
 
322
+ // Build-time gate for `child="x"` placement: the marker is read only off a
323
+ // DIRECT element child of a component tag. Anywhere else — inside a `{…}`
324
+ // expression, nested deeper, at the template root — it silently lands in the
325
+ // default slot, and on a component tag it is an ignored prop. Found in the
326
+ // wild: `{cond ? <div child="top"/> : null}` rendered inside <main> with no
327
+ // warning.
328
+ const childMarkerOf = (attrs: ts.JsxAttributes): ts.JsxAttribute | undefined =>
329
+ attrs.properties.find(
330
+ (a): a is ts.JsxAttribute =>
331
+ ts.isJsxAttribute(a) && !ts.isJsxNamespacedName(a.name) && a.name.getText(sf) === 'child',
332
+ )
333
+ const checkChildMarkers = (node: ts.Node, underComponent: boolean): void => {
334
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
335
+ const opening = ts.isJsxElement(node) ? node.openingElement : node
336
+ const tag = opening.tagName.getText(sf)
337
+ const marker = childMarkerOf(opening.attributes)
338
+ if (marker) {
339
+ const region =
340
+ marker.initializer && ts.isStringLiteral(marker.initializer)
341
+ ? marker.initializer.text
342
+ : 'x'
343
+ if (isComponentTag(tag)) {
344
+ throw new CompileError(
345
+ `stator: child="${region}" on <${tag}/> has no effect — a child="x" marker is only ` +
346
+ `read on a direct element child of a component tag; on a component it is an ` +
347
+ `ignored prop. Wrap it: <div child="${region}"><${tag}/></div>`,
348
+ loc(marker),
349
+ )
350
+ }
351
+ if (!underComponent) {
352
+ throw new CompileError(
353
+ `stator: child="${region}" has no effect here — a child="x" marker is only read on ` +
354
+ `a direct element child of a component tag. Move the condition inside the slotted ` +
355
+ `element: <div child="${region}">{cond ? … : null}</div>`,
356
+ loc(marker),
357
+ )
358
+ }
359
+ }
360
+ checkChildMarkers(opening.attributes, false)
361
+ if (ts.isJsxElement(node)) {
362
+ const inner = isComponentTag(tag)
363
+ for (const child of node.children) checkChildMarkers(child, inner)
364
+ }
365
+ return
366
+ }
367
+ ts.forEachChild(node, (child) => checkChildMarkers(child, false))
368
+ }
369
+ checkChildMarkers(fragment, false)
370
+
322
371
  // Build-time gate for item-read placement: a `read(<item>, …)` binding is
323
372
  // OWNED by its each() row — the row render supplies the item and collects the
324
373
  // binding, and the list's recompute is what re-diffs it. Three positions break
@@ -21,6 +21,7 @@
21
21
  import ts from 'typescript'
22
22
  import { VOID_ELEMENTS } from '../template/void-elements.ts'
23
23
  import { analyzeScriptClasses } from './client-script.ts'
24
+ import { parseDiagnosticsOf } from './diagnostics.ts'
24
25
  import { statorPropsType } from './dts.ts'
25
26
  import { CLIENT_AUTHOR_GLOBALS, TEMPLATE_AUTHOR_GLOBALS } from './emit-names.ts'
26
27
  import { type ScannedRegions, scanRegions } from './split.ts'
@@ -146,10 +147,7 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
146
147
  STATOR_AMBIENT
147
148
 
148
149
  const { hoisted, body } = splitFrontmatter(fm, fmOffset)
149
- for (const seg of hoisted) {
150
- push(mappings, seg.sourceOffset, code.length, seg.text.length)
151
- code += `${seg.text}\n`
152
- }
150
+ for (const seg of hoisted) code = emitSegment(mappings, code, seg)
153
151
 
154
152
  // A leading <!doctype> is static HTML, not JSX — drop it from the shell (no
155
153
  // intelligence lost) and advance the template mapping past it.
@@ -175,11 +173,7 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
175
173
  code += "type __SReadResult<T> = import('@statorjs/stator/template').ReadResult<T>\n"
176
174
  }
177
175
  code += `export default function (_props: ${propsT}) {\n`
178
- for (const seg of body) {
179
- code += ' '
180
- push(mappings, seg.sourceOffset, code.length, seg.text.length)
181
- code += `${seg.text}\n`
182
- }
176
+ for (const seg of body) code = emitSegment(mappings, `${code} `, seg)
183
177
  code += ' return (<>'
184
178
  code = appendTemplateTsx(code, mappings, tpl, tplOffset)
185
179
  code += '</>);\n}\n'
@@ -298,6 +292,16 @@ function expressionEnd(tpl: string, start: number): number {
298
292
  interface FmSegment {
299
293
  sourceOffset: number
300
294
  text: string
295
+ /** The statement has a syntax error in TS mode (JSX in the fence, usually).
296
+ * Emitted as a string literal so the TSX model can't make it look fine —
297
+ * the compiler's located error is the one the editor shows. */
298
+ opaque?: boolean
299
+ }
300
+
301
+ function emitSegment(mappings: VirtualMapping[], code: string, seg: FmSegment): string {
302
+ if (seg.opaque) return `${code}${JSON.stringify(seg.text)};\n`
303
+ push(mappings, seg.sourceOffset, code.length, seg.text.length)
304
+ return `${code}${seg.text}\n`
301
305
  }
302
306
 
303
307
  /**
@@ -315,8 +319,21 @@ function splitFrontmatter(
315
319
  const body: FmSegment[] = []
316
320
  if (!fm.trim()) return { hoisted, body }
317
321
  const sf = ts.createSourceFile('fm.ts', fm, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
322
+ // A syntax error splits what the author wrote as one statement into pieces
323
+ // (`return <p>not` / `found</p>`), and the piece without the diagnostic would
324
+ // read as valid JSX in the TSX model. Every statement sharing a line with a
325
+ // diagnostic is opaque.
326
+ const brokenLines = new Set(
327
+ parseDiagnosticsOf(sf).map((d) => sf.getLineAndCharacterOfPosition(d.start).line),
328
+ )
318
329
  for (const stmt of sf.statements) {
319
- const seg: FmSegment = { sourceOffset: fmOffset + stmt.getStart(sf), text: stmt.getText(sf) }
330
+ const start = stmt.getStart(sf)
331
+ const seg: FmSegment = { sourceOffset: fmOffset + start, text: stmt.getText(sf) }
332
+ const first = sf.getLineAndCharacterOfPosition(start).line
333
+ const last = sf.getLineAndCharacterOfPosition(stmt.getEnd()).line
334
+ for (let line = first; line <= last; line++) {
335
+ if (brokenLines.has(line)) seg.opaque = true
336
+ }
320
337
  if (
321
338
  ts.isImportDeclaration(stmt) ||
322
339
  ts.isTypeAliasDeclaration(stmt) ||
@@ -352,15 +369,10 @@ function buildClientTsx(regions: ScannedRegions): VirtualFile {
352
369
  const { hoisted, body } = splitFrontmatter(fm, fmOffset)
353
370
  for (const seg of hoisted) {
354
371
  if (userScript.includes(seg.text.trim())) continue
355
- push(mappings, seg.sourceOffset, code.length, seg.text.length)
356
- code += `${seg.text}\n`
372
+ code = emitSegment(mappings, code, seg)
357
373
  }
358
374
  code += 'function __statorFence(props: Record<string, unknown>) {\n'
359
- for (const seg of body) {
360
- code += ' '
361
- push(mappings, seg.sourceOffset, code.length, seg.text.length)
362
- code += `${seg.text}\n`
363
- }
375
+ for (const seg of body) code = emitSegment(mappings, `${code} `, seg)
364
376
  code += '}\nvoid __statorFence\n'
365
377
  }
366
378
 
@@ -1,7 +1,7 @@
1
- /* components/image.stator.ts */
1
+ /* src/components/image.stator.ts */
2
2
  /* height:auto preserves the aspect ratio the width/height attributes
3
3
  declare; radius, margin, object-fit are the app's call. */
4
- :where(img[data-s-6e2813d6]) {
4
+ :where(img[data-s-ff0b03fd]) {
5
5
  display: block;
6
6
  max-width: 100%;
7
7
  height: auto;
@@ -1,6 +1,6 @@
1
1
  /* height:auto preserves the aspect ratio the width/height attributes
2
2
  declare; radius, margin, object-fit are the app's call. */
3
- :where(img[data-s-6e2813d6]) {
3
+ :where(img[data-s-ff0b03fd]) {
4
4
  display: block;
5
5
  max-width: 100%;
6
6
  height: auto;
@@ -25,5 +25,5 @@ export default function (props: ImageProps) {
25
25
  decoding = 'async',
26
26
  } = p
27
27
  const img = getImage(p)
28
- return html`<img src="${img.src}" srcset="${img.srcset ?? undefined}" sizes="${img.srcset ? sizes : undefined}" alt="${alt}" width="${img.width}" height="${img.height}" loading="${loading}" fetchpriority="${fetchpriority}" decoding="${decoding}" class="${className}" data-s-6e2813d6>`
28
+ return html`<img src="${img.src}" srcset="${img.srcset ?? undefined}" sizes="${img.srcset ? sizes : undefined}" alt="${alt}" width="${img.width}" height="${img.height}" loading="${loading}" fetchpriority="${fetchpriority}" decoding="${decoding}" class="${className}" data-s-ff0b03fd>`
29
29
  }
@@ -15,7 +15,7 @@ import type {
15
15
  Directive,
16
16
  RouteRequest,
17
17
  } from './routing.ts'
18
- import { getOrCreateSessionId, getSessionState, rotateSessionNow, sessionUse } from './session.ts'
18
+ import { getOrCreateSessionId, getSessionState, readClaims, rotateSessionNow } from './session.ts'
19
19
  import { withSessionLock } from './session-lock.ts'
20
20
  import { SessionRuntime } from './session-runtime.ts'
21
21
  import { fanOut } from './sse.ts'
@@ -76,10 +76,7 @@ export async function runApiRoute(
76
76
  clearSession: () => {
77
77
  rotation = { clear: true }
78
78
  },
79
- claims: <T = unknown>() => {
80
- sessionUse(c).claimsRead = true
81
- return getSessionState(c)?.claims as T | undefined
82
- },
79
+ claims: <T = unknown>() => readClaims<T>(c),
83
80
  setClaims: (claims) => {
84
81
  const s = getSessionState(c)
85
82
  if (s) {
@@ -1,6 +1,6 @@
1
1
  import type { Context } from 'hono'
2
2
  import { type CookieJar, cookieJar } from './cookies.ts'
3
- import { getOrCreateSessionId, getSessionState, rotateSessionNow, sessionUse } from './session.ts'
3
+ import { getOrCreateSessionId, getSessionState, readClaims, rotateSessionNow } from './session.ts'
4
4
  import type { Store } from './store.ts'
5
5
 
6
6
  /**
@@ -75,11 +75,7 @@ export function stator(c: Context): StatorContext {
75
75
  return getSessionState(c)?.sid ?? ''
76
76
  },
77
77
  claims<T = unknown>(): T | undefined {
78
- // A claims READ is a peek — it never establishes (an anonymous /admin
79
- // probe redirects without minting state) — but it does make the
80
- // response claims-dependent, so layer 3 marks it unprovable.
81
- sessionUse(c).claimsRead = true
82
- return getSessionState(c)?.claims as T | undefined
78
+ return readClaims<T>(c)
83
79
  },
84
80
  setClaims(claims: unknown): void {
85
81
  // Writing claims is an explicit session op — an establishment trigger.
@@ -1,6 +1,7 @@
1
1
  import type { Context } from 'hono'
2
2
  import { deleteCookie, getCookie, getSignedCookie, setCookie, setSignedCookie } from 'hono/cookie'
3
3
  import type { RouteCookieOptions } from './routing.ts'
4
+ import { sessionUse } from './session.ts'
4
5
 
5
6
  /**
6
7
  * A focused read/write cookie surface for app-owned cookies (a login
@@ -49,7 +50,10 @@ function requireSecret(c: Context): string {
49
50
  * the same way. */
50
51
  export function cookieJar(c: Context): CookieJar {
51
52
  return {
52
- get: (name) => getCookie(c, name),
53
+ get: (name) => {
54
+ sessionUse(c).varied = true
55
+ return getCookie(c, name)
56
+ },
53
57
  set: (name, value, options) => {
54
58
  setCookie(c, name, value, options as never)
55
59
  },
@@ -57,6 +61,7 @@ export function cookieJar(c: Context): CookieJar {
57
61
  deleteCookie(c, name, options as never)
58
62
  },
59
63
  getSigned: async (name) => {
64
+ sessionUse(c).varied = true
60
65
  // hono returns `false` for a present-but-invalid signature; collapse it to
61
66
  // undefined so callers never trust (or even see) a tampered value.
62
67
  const value = await getSignedCookie(c, requireSecret(c), name)
@@ -68,9 +68,12 @@ export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
68
68
 
69
69
  // Load the runtime through Vite so it shares an instance with the templates.
70
70
  // (Type-only: the static import type is erased, so no second instance.)
71
- const runtime = (await vite.ssrLoadModule(
72
- '@statorjs/stator/server',
73
- )) as typeof import('./index.ts')
71
+ // Both subpaths through Vite's graph, so the plumbing shares module
72
+ // instances with the app code Vite loads.
73
+ const runtime = {
74
+ ...(await vite.ssrLoadModule('@statorjs/stator/server')),
75
+ ...(await vite.ssrLoadModule('@statorjs/stator/server/runtime')),
76
+ } as typeof import('./index.ts') & typeof import('./runtime.ts')
74
77
  const loader = (file: string) => vite.ssrLoadModule(file) as Promise<Record<string, unknown>>
75
78
 
76
79
  const root = resolve(config.root)
@@ -914,8 +914,9 @@ async function renderGetCandidate(
914
914
  }
915
915
 
916
916
  // Layer 3 — derived Cache-Control: the page is PROVABLY anonymous-
917
- // identical (no session reads declared, no session use or claims read
918
- // during handling) and the route didn't hand-set the header. The
917
+ // identical (no session reads declared, no session use, claims or
918
+ // request inputs read during handling) and the route didn't hand-set
919
+ // the header. The
919
920
  // framework says what it can prove; hand-set always wins. Computed here
920
921
  // but only ever WRITTEN from `commit`, below — this render may be a
921
922
  // losing, discarded candidate under cascading, and only the winner's
@@ -927,6 +928,7 @@ async function renderGetCandidate(
927
928
  !needsSession &&
928
929
  !use.used &&
929
930
  !use.claimsRead &&
931
+ !use.varied &&
930
932
  result.response.status < 300 &&
931
933
  result.response.cookies.length === 0 &&
932
934
  !result.response.headers.has('cache-control')
@@ -1,12 +1,11 @@
1
1
  /**
2
- * `/server` carries two tiers (see the docs' stability policy): the STABLE
3
- * app surface (createApp, defineMachine, defineApiRoute, the stores, logger,
4
- * dispatchToApp, rotateSession) and the TOOLCHAIN plumbing the compiler/Vite
5
- * module graph needs importable (recompute, renderRoute, buildHonoApp,
6
- * SessionRuntime, render-context internals, …). The Toolchain tier is not
7
- * covered by semver and is RESERVED to move to a dedicated subpath
8
- * (`@statorjs/stator/server/runtime`) in a 2.x minor — import it expecting
9
- * that move.
2
+ * `/server` is the STABLE app surface (see the docs' stability policy):
3
+ * createApp, defineMachine, the route definers, the stores, logger,
4
+ * dispatchToApp, middleware and security, images. The TOOLCHAIN plumbing the
5
+ * compiled output, dev server and build run on lives on `/server/runtime`,
6
+ * where the import path itself carries the tier. It is still re-exported
7
+ * here (the last block) so a 2.x import keeps resolving; that re-export is
8
+ * deprecated and goes away in the next major.
10
9
  */
11
10
  export type {
12
11
  ElementTarget,
@@ -19,7 +18,7 @@ export { dispatchToApp } from './app-dispatch.ts'
19
18
  export type { AppStore } from './app-store.ts'
20
19
  export { InMemoryAppStore } from './app-store.ts'
21
20
  export type { BootContext, BootDefinition, BootFn, BootTeardown } from './boot.ts'
22
- export { defineBoot, discoverBoot, isBootDefinition, runBoot } from './boot.ts'
21
+ export { defineBoot } from './boot.ts'
23
22
  export type { CachedStoreOptions } from './cached-store.ts'
24
23
  export { CachedStore } from './cached-store.ts'
25
24
  export type { StatorContext } from './context.ts'
@@ -38,72 +37,13 @@ export type {
38
37
  SubscribeEntry,
39
38
  SubscribeEvent,
40
39
  } from './define-machine.ts'
41
- export { defineMachine, isStatorMachine } from './define-machine.ts'
42
- export type { PollLoopFinding } from './dev-lint.ts'
43
- export { findPollLoops } from './dev-lint.ts'
44
- export type { DiscoveryResult } from './discovery.ts'
45
- export { discoverMachines } from './discovery.ts'
46
- export type { DispatchContext } from './dispatch-context.ts'
47
- export {
48
- getDispatchContext,
49
- recordTouch,
50
- withDispatchContext,
51
- } from './dispatch-context.ts'
52
- export { scheduleSessionEffects, wireAppEffects } from './effects.ts'
53
- export type { HttpConfig } from './http.ts'
54
- export { buildHonoApp } from './http.ts'
55
- export type { ImageTransformer, ResolvedImagesConfig } from './images.ts'
56
- export {
57
- DEFAULT_IMAGE_WIDTHS,
58
- probeImage,
59
- resolveImagesConfig,
60
- sharpTransformer,
61
- } from './images.ts'
62
- export type { InspectPayload, InspectRoute, InspectRouteMethod } from './inspect.ts'
63
- export { buildInspectPayload } from './inspect.ts'
64
- export type { InstanceHandle } from './instance-proxy.ts'
65
- export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
40
+ export { defineMachine } from './define-machine.ts'
41
+ export type { ImageTransformer } from './images.ts'
42
+ export { probeImage, sharpTransformer } from './images.ts'
66
43
  export { logger, scopedLogger, setLogLevel } from './logger.ts'
67
- export { codeHashOf, codeInputsOf } from './machine-hash.ts'
68
- export { MachineStore } from './machine-store.ts'
69
44
  export type { MiddlewareDefinition } from './middleware.ts'
70
- export {
71
- dangerouslyDefineMiddleware,
72
- defineMiddleware,
73
- discoverMiddleware,
74
- isMiddlewareDefinition,
75
- } from './middleware.ts'
76
- export { runQueryRoute } from './query-route.ts'
77
- export { recompute } from './recompute.ts'
45
+ export { dangerouslyDefineMiddleware, defineMiddleware } from './middleware.ts'
78
46
  export { RedisAppStore, RedisStore } from './redis-store.ts'
79
- export type { RenderResult } from './render.ts'
80
- export { renderRoute } from './render.ts'
81
- export type {
82
- Binding,
83
- BindingKind,
84
- ElementId,
85
- EventDescriptor,
86
- MachineName,
87
- RenderState,
88
- SessionId,
89
- SlotId,
90
- } from './render-context.ts'
91
- export {
92
- allocElementId,
93
- allocSlotId,
94
- createEventDescriptor,
95
- createRenderState,
96
- getCurrentRenderState,
97
- isEventDescriptor,
98
- popListScope,
99
- pushListScope,
100
- registerBinding,
101
- requireCurrentRenderState,
102
- runInRender,
103
- unregisterBindingsForScope,
104
- } from './render-context.ts'
105
- export type { DiscoveredRoute } from './route-discovery.ts'
106
- export { discoverRoutes } from './route-discovery.ts'
107
47
  export type {
108
48
  ApiRouteDefinition,
109
49
  ApiRouteEnvelope,
@@ -123,24 +63,13 @@ export type {
123
63
  RouteRequest,
124
64
  RouteResponseContext,
125
65
  } from './routing.ts'
126
- export {
127
- defineApiRoute,
128
- defineRoute,
129
- isStatorApiRoute,
130
- isStatorQueryRoute,
131
- isStatorRoute,
132
- } from './routing.ts'
66
+ export { defineApiRoute, defineRoute } from './routing.ts'
67
+ /** @deprecated Import from `@statorjs/stator/server/runtime`. This re-export
68
+ * of the Toolchain tier is removed in the next major. */
69
+ export * from './runtime.ts'
133
70
  export type { SecurityHeadersOptions } from './security-headers.ts'
134
71
  export { securityHeaders } from './security-headers.ts'
135
- export { getOrCreateSessionId, SESSION_COOKIE, setSessionSameSite } from './session.ts'
136
- export { SessionRuntime } from './session-runtime.ts'
137
- export type { Connection } from './sse.ts'
138
- export {
139
- activeConnectionCount,
140
- fanOut,
141
- registerConnection,
142
- unregisterConnection,
143
- } from './sse.ts'
72
+ export { activeConnectionCount } from './sse.ts'
144
73
  export type { Store } from './store.ts'
145
74
  export { InMemoryStore } from './store.ts'
146
75
  export type { StoreFromEnvOptions } from './store-intent.ts'
@@ -28,6 +28,7 @@ const DATA_CONTENT_TYPES: Record<string, string> = {
28
28
  '.txt': 'text/plain; charset=utf-8',
29
29
  '.ics': 'text/calendar; charset=utf-8',
30
30
  '.csv': 'text/csv; charset=utf-8',
31
+ '.css': 'text/css; charset=utf-8',
31
32
  }
32
33
 
33
34
  /** Second-level file extensions (`feed.xml.ts`) that mark a route file as
@@ -141,8 +142,8 @@ export async function runQueryRoute(
141
142
  }
142
143
 
143
144
  /** Layer 3 — derived Cache-Control, same proof as pages: no session reads
144
- * declared, no session use/claims read while handling, handler set nothing
145
- * itself. Applied to 200 and 304 alike (a 304's headers update the cached
145
+ * declared, no session use, claims or request inputs read while handling,
146
+ * handler set nothing itself. Applied to 200 and 304 alike (a 304's headers update the cached
146
147
  * entry's lifetime). */
147
148
  function applyDerivedCaching(
148
149
  c: Context,
@@ -155,6 +156,7 @@ function applyDerivedCaching(
155
156
  opts.needsSession ||
156
157
  use.used ||
157
158
  use.claimsRead ||
159
+ use.varied ||
158
160
  res.status >= 400 ||
159
161
  res.headers.has('cache-control')
160
162
  ) {
@@ -1,5 +1,20 @@
1
1
  import type { Context } from 'hono'
2
2
  import type { RouteRequest } from './routing.ts'
3
+ import { readClaims, sessionUse } from './session.ts'
4
+
5
+ /** A read-tracking view over the request's headers. Any read marks the
6
+ * response as request-varying (layer 3: a page that consulted a header may
7
+ * differ per visitor, so it is never cache-marked). Methods are bound to the
8
+ * real Headers, so the view behaves as one. */
9
+ function trackedHeaders(c: Context, headers: Headers): Headers {
10
+ return new Proxy(headers, {
11
+ get(target, prop) {
12
+ sessionUse(c).varied = true
13
+ const value = Reflect.get(target, prop, target)
14
+ return typeof value === 'function' ? value.bind(target) : value
15
+ },
16
+ })
17
+ }
3
18
 
4
19
  /**
5
20
  * Wrap Hono's request into the framework's RouteRequest shape. The raw
@@ -14,6 +29,7 @@ export function buildRouteRequest(c: Context, paramNames: string[]): RouteReques
14
29
  if (v !== undefined) params[name] = v
15
30
  }
16
31
  const query: Record<string, string | undefined> = c.req.query()
32
+ const headers = trackedHeaders(c, raw.headers)
17
33
 
18
34
  return {
19
35
  raw,
@@ -26,8 +42,9 @@ export function buildRouteRequest(c: Context, paramNames: string[]): RouteReques
26
42
  return raw.url
27
43
  },
28
44
  get headers() {
29
- return raw.headers
45
+ return headers
30
46
  },
47
+ claims: <T = unknown>() => readClaims<T>(c),
31
48
  formData: () => raw.formData(),
32
49
  json: <T = unknown>() => raw.json() as Promise<T>,
33
50
  text: () => raw.text(),
@@ -27,8 +27,13 @@ export interface RouteRequest {
27
27
  readonly method: string
28
28
  /** Full request URL. Same as `raw.url`. */
29
29
  readonly url: string
30
- /** Request headers. Same Headers instance as `raw.headers`. */
30
+ /** Request headers. Reading one marks the response as varying per visitor,
31
+ * which withdraws derived Cache-Control; `raw.headers` is the untracked
32
+ * escape hatch and is outside that proof. */
31
33
  readonly headers: Headers
34
+ /** This session's app-defined claims, or `undefined` if none. A peek: never
35
+ * establishes a session. A defined result withdraws derived Cache-Control. */
36
+ claims<T = unknown>(): T | undefined
32
37
  /** Parsed form body. Throws if the content type doesn't match. */
33
38
  formData(): Promise<FormData>
34
39
  /** Parsed JSON body. Throws on invalid JSON. */
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `/server/runtime` — the TOOLCHAIN tier of the server: what compiled `.stator`
3
+ * output, the dev server, the build and the framework's own tests run on.
4
+ * Public because they must be importable from a package boundary, not
5
+ * because an app should need them; per the docs' stability policy these may
6
+ * change in a minor. The STABLE app surface lives on `/server`.
7
+ */
8
+ export { discoverBoot, isBootDefinition, runBoot } from './boot.ts'
9
+ export { isStatorMachine } from './define-machine.ts'
10
+ export type { PollLoopFinding } from './dev-lint.ts'
11
+ export { findPollLoops } from './dev-lint.ts'
12
+ export type { DiscoveryResult } from './discovery.ts'
13
+ export { discoverMachines } from './discovery.ts'
14
+ export type { DispatchContext } from './dispatch-context.ts'
15
+ export {
16
+ getDispatchContext,
17
+ recordTouch,
18
+ withDispatchContext,
19
+ } from './dispatch-context.ts'
20
+ export { scheduleSessionEffects, wireAppEffects } from './effects.ts'
21
+ export type { HttpConfig } from './http.ts'
22
+ export { buildHonoApp } from './http.ts'
23
+ export type { ResolvedImagesConfig } from './images.ts'
24
+ export { DEFAULT_IMAGE_WIDTHS, resolveImagesConfig } from './images.ts'
25
+ export type { InspectPayload, InspectRoute, InspectRouteMethod } from './inspect.ts'
26
+ export { buildInspectPayload } from './inspect.ts'
27
+ export type { InstanceHandle } from './instance-proxy.ts'
28
+ export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
29
+ export { codeHashOf, codeInputsOf } from './machine-hash.ts'
30
+ export { MachineStore } from './machine-store.ts'
31
+ export { discoverMiddleware, isMiddlewareDefinition } from './middleware.ts'
32
+ export { runQueryRoute } from './query-route.ts'
33
+ export { recompute } from './recompute.ts'
34
+ export type { RenderResult } from './render.ts'
35
+ export { renderRoute } from './render.ts'
36
+ export type {
37
+ Binding,
38
+ BindingKind,
39
+ ElementId,
40
+ EventDescriptor,
41
+ MachineName,
42
+ RenderState,
43
+ SessionId,
44
+ SlotId,
45
+ } from './render-context.ts'
46
+ export {
47
+ allocElementId,
48
+ allocSlotId,
49
+ createEventDescriptor,
50
+ createRenderState,
51
+ getCurrentRenderState,
52
+ isEventDescriptor,
53
+ popListScope,
54
+ pushListScope,
55
+ registerBinding,
56
+ requireCurrentRenderState,
57
+ runInRender,
58
+ unregisterBindingsForScope,
59
+ } from './render-context.ts'
60
+ export type { DiscoveredRoute } from './route-discovery.ts'
61
+ export { discoverRoutes } from './route-discovery.ts'
62
+ export { isStatorApiRoute, isStatorQueryRoute, isStatorRoute } from './routing.ts'
63
+ export { getOrCreateSessionId, SESSION_COOKIE, setSessionSameSite } from './session.ts'
64
+ export { SessionRuntime } from './session-runtime.ts'
65
+ export type { Connection } from './sse.ts'
66
+ export { fanOut, registerConnection, unregisterConnection } from './sse.ts'
@@ -28,7 +28,11 @@ export interface SessionState {
28
28
  * response unprovable as anonymous-identical, so no derived Cache-Control. */
29
29
  export interface SessionUse {
30
30
  used: boolean
31
+ /** A claims read resolved to a value. */
31
32
  claimsRead: boolean
33
+ /** A request input beyond the URL was read (a header, a cookie), so the
34
+ * response may differ per visitor. */
35
+ varied: boolean
32
36
  }
33
37
 
34
38
  declare module 'hono' {
@@ -47,12 +51,21 @@ export function getSessionState(c: Context): SessionState | undefined {
47
51
  export function sessionUse(c: Context): SessionUse {
48
52
  let use = c.get('statorSessionUse')
49
53
  if (!use) {
50
- use = { used: false, claimsRead: false }
54
+ use = { used: false, claimsRead: false, varied: false }
51
55
  c.set('statorSessionUse', use)
52
56
  }
53
57
  return use
54
58
  }
55
59
 
60
+ /** Read the resumed session's claims — a peek that never establishes. Only a
61
+ * DEFINED result makes the response claims-dependent (layer 3): a visitor
62
+ * whose claims read came back empty saw exactly the anonymous page. */
63
+ export function readClaims<T = unknown>(c: Context): T | undefined {
64
+ const claims = getSessionState(c)?.claims as T | undefined
65
+ if (claims !== undefined) sessionUse(c).claimsRead = true
66
+ return claims
67
+ }
68
+
56
69
  /** The session id this request WOULD have — established state or the arriving
57
70
  * cookie — without creating anything. Null for a first-contact visitor. */
58
71
  export function peekSessionId(c: Context): string | null {