@statorjs/stator 2.11.0 → 2.13.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 +8 -3
- package/src/build/compile-component.ts +1 -2
- package/src/build/compile-library.ts +91 -0
- package/src/build/index.ts +2 -0
- package/src/build/sync.ts +3 -1
- package/src/cli/commands/build-lib.ts +24 -0
- package/src/cli/commands/check.ts +40 -4
- package/src/cli/commands/test.ts +18 -1
- package/src/cli/run.ts +3 -2
- package/src/cli/stator.js +9 -10
- package/src/compiler/compile.ts +79 -13
- package/src/compiler/diagnostics.ts +13 -0
- package/src/compiler/lower.ts +49 -0
- package/src/compiler/virtual-code.ts +29 -17
- package/src/components/components.css +2 -2
- package/src/components/image.stator.css +1 -1
- package/src/components/image.stator.ts +1 -1
- package/src/register.js +21 -0
- package/src/server/api-route.ts +2 -5
- package/src/server/context.ts +2 -6
- package/src/server/cookies.ts +6 -1
- package/src/server/dev-vite.ts +43 -7
- package/src/server/http.ts +4 -2
- package/src/server/index.ts +17 -88
- package/src/server/query-route.ts +4 -2
- package/src/server/route-request.ts +18 -1
- package/src/server/routing.ts +6 -1
- package/src/server/runtime.ts +66 -0
- package/src/server/session.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.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"
|
|
@@ -43,6 +47,7 @@
|
|
|
43
47
|
"types": "./src/server/dev.ts",
|
|
44
48
|
"default": "./src/server/dev.ts"
|
|
45
49
|
},
|
|
50
|
+
"./register": "./src/register.js",
|
|
46
51
|
"./build": {
|
|
47
52
|
"types": "./src/build/index.ts",
|
|
48
53
|
"default": "./src/build/index.ts"
|
|
@@ -101,7 +106,7 @@
|
|
|
101
106
|
"pino-pretty": "^13.2.0",
|
|
102
107
|
"vite": "^6.4.5",
|
|
103
108
|
"vitest": "^3.2.7",
|
|
104
|
-
"@statorjs/stator": "2.
|
|
109
|
+
"@statorjs/stator": "2.13.0"
|
|
105
110
|
},
|
|
106
111
|
"scripts": {
|
|
107
112
|
"typecheck": "tsc --noEmit",
|
|
@@ -109,6 +114,6 @@
|
|
|
109
114
|
"test:watch": "vitest",
|
|
110
115
|
"test:browser": "node browser-tests/tables.mjs",
|
|
111
116
|
"build:client": "esbuild src/client/runtime.ts --bundle --format=iife --target=es2020 --outfile=dist/client.js",
|
|
112
|
-
"build:components": "node
|
|
117
|
+
"build:components": "node src/cli/stator.js build:lib src/components && biome check --write src/components/"
|
|
113
118
|
}
|
|
114
119
|
}
|
|
@@ -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
|
+
}
|
package/src/build/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
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)
|
|
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
|
-
|
|
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/commands/test.ts
CHANGED
|
@@ -3,6 +3,23 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import type { CliContext } from '../run.ts'
|
|
5
5
|
|
|
6
|
+
/** `@statorjs/stator/register`, by absolute URL so it resolves whatever the
|
|
7
|
+
* runner's working directory. */
|
|
8
|
+
const REGISTER = new URL('../../register.js', import.meta.url).href
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The runner's environment: `NODE_OPTIONS` gains `--import` of Stator's
|
|
12
|
+
* loader, kept alongside anything already there. Stator ships TypeScript,
|
|
13
|
+
* and a runner hands `node_modules` to Node — so Stator, and every package
|
|
14
|
+
* built on it (direct or transitive, hoisted or not), would otherwise reach
|
|
15
|
+
* Node unloadable. Child processes and workers inherit it.
|
|
16
|
+
*/
|
|
17
|
+
export function testEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
18
|
+
const flag = `--import ${REGISTER}`
|
|
19
|
+
const current = env.NODE_OPTIONS?.trim() ?? ''
|
|
20
|
+
return { ...env, NODE_OPTIONS: current.includes(flag) ? current : `${current} ${flag}`.trim() }
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
/**
|
|
7
24
|
* Run the test suite. The runner is a deliberately swappable seam — today it
|
|
8
25
|
* wraps the app's local Vitest; as the platform matures this becomes
|
|
@@ -22,7 +39,7 @@ export async function run(ctx: CliContext): Promise<void> {
|
|
|
22
39
|
}
|
|
23
40
|
const args = ctx.rest.length > 0 ? ctx.rest : ['run']
|
|
24
41
|
await new Promise<void>((resolvePromise, reject) => {
|
|
25
|
-
const child = spawn(bin, args, { stdio: 'inherit', cwd: ctx.root })
|
|
42
|
+
const child = spawn(bin, args, { stdio: 'inherit', cwd: ctx.root, env: testEnv() })
|
|
26
43
|
child.on('error', reject)
|
|
27
44
|
child.on('exit', (code) =>
|
|
28
45
|
code === 0 ? resolvePromise() : reject(new Error(`tests failed (exit ${code})`)),
|
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
|
|
package/src/cli/stator.js
CHANGED
|
@@ -21,16 +21,15 @@ if (typeof register !== 'function') {
|
|
|
21
21
|
process.exit(1)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
process.setSourceMapsEnabled?.(true)
|
|
24
|
+
// The loader and source maps — the same registration `--import
|
|
25
|
+
// @statorjs/stator/register` gives a process the CLI doesn't start. Source
|
|
26
|
+
// maps: every transform in the owned pipeline (the TS loader, the dev
|
|
27
|
+
// loader's `.stator` compile) already EMITS an inline map, but V8 ignores
|
|
28
|
+
// maps when capturing stacks unless the process opts in — the runtime
|
|
29
|
+
// equivalent of `--enable-source-maps`, which a bin script can't put on its
|
|
30
|
+
// own invocation. (`.stator` traces resolve to the compiled server module —
|
|
31
|
+
// right file, generated lines — until the compiler emits its own map.)
|
|
32
|
+
await import('../register.js')
|
|
34
33
|
|
|
35
34
|
// The real CLI is TypeScript, transformed on import by the loader just registered.
|
|
36
35
|
await import('./run.ts')
|
package/src/compiler/compile.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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)
|
package/src/compiler/lower.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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-
|
|
4
|
+
:where(img[data-s-ff0b03fd]) {
|
|
5
5
|
display: block;
|
|
6
6
|
max-width: 100%;
|
|
7
7
|
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-
|
|
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
|
}
|
package/src/register.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// `--import @statorjs/stator/register`: Stator's TS loader for any process
|
|
2
|
+
// the `stator` CLI doesn't start — a test runner launched by an editor,
|
|
3
|
+
// `npx vitest`, `node scripts/x.ts`. Stator ships TypeScript source, and Node
|
|
4
|
+
// won't strip types inside node_modules (or, from a linked checkout, strips
|
|
5
|
+
// only a subset), so anything that imports Stator, or a package built on it,
|
|
6
|
+
// needs this loader. `stator dev`/`start`/`build` register it themselves and
|
|
7
|
+
// `stator test` passes it to the runner. Plain JS so Node can import it with
|
|
8
|
+
// no loader in place yet.
|
|
9
|
+
import nodeModule from 'node:module'
|
|
10
|
+
|
|
11
|
+
if (typeof nodeModule.register !== 'function') {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`@statorjs/stator/register needs module.register — Node 18.19+ or 20.6+ (found ${process.version}).`,
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
nodeModule.register('./cli/loader.js', import.meta.url)
|
|
18
|
+
|
|
19
|
+
// Every transform the loader makes emits an inline map; V8 only applies
|
|
20
|
+
// them to stack traces when the process opts in.
|
|
21
|
+
process.setSourceMapsEnabled?.(true)
|
package/src/server/api-route.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
Directive,
|
|
16
16
|
RouteRequest,
|
|
17
17
|
} from './routing.ts'
|
|
18
|
-
import { getOrCreateSessionId, getSessionState,
|
|
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) {
|
package/src/server/context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Context } from 'hono'
|
|
2
2
|
import { type CookieJar, cookieJar } from './cookies.ts'
|
|
3
|
-
import { getOrCreateSessionId, getSessionState,
|
|
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
|
-
|
|
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.
|
package/src/server/cookies.ts
CHANGED
|
@@ -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) =>
|
|
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)
|
package/src/server/dev-vite.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { existsSync } from 'node:fs'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
4
|
import { createServer as createHttpServer } from 'node:http'
|
|
5
|
-
import { relative, resolve } from 'node:path'
|
|
5
|
+
import { join, relative, resolve } from 'node:path'
|
|
6
6
|
import { getRequestListener } from '@hono/node-server'
|
|
7
7
|
import type { Hono } from 'hono'
|
|
8
8
|
import { createServer as createViteServer, type ViteDevServer } from 'vite'
|
|
@@ -36,6 +36,36 @@ import { isStatorQueryRoute } from './routing.ts'
|
|
|
36
36
|
* whose render-context doesn't match the one the templates resolve against, and
|
|
37
37
|
* `read()` would throw. (This mirrors how Astro/SvelteKit run SSR through Vite.)
|
|
38
38
|
*/
|
|
39
|
+
/**
|
|
40
|
+
* The app's direct dependencies that depend on Stator, for `ssr.noExternal`
|
|
41
|
+
* above. Transitional, like this whole module (removed with the Vite exit):
|
|
42
|
+
* the native dev server and production load one module graph, where the
|
|
43
|
+
* dual-instance trap can't happen. Direct dependencies only — a deeper one
|
|
44
|
+
* still loads (`@statorjs/stator/register` handles that) but as a second
|
|
45
|
+
* instance.
|
|
46
|
+
*/
|
|
47
|
+
function statorDependents(root: string): string[] {
|
|
48
|
+
const read = (path: string): Record<string, Record<string, string> | undefined> | null => {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const app = read(join(root, 'package.json'))
|
|
56
|
+
if (!app) return []
|
|
57
|
+
return Object.keys({ ...app.dependencies, ...app.devDependencies }).filter((name) => {
|
|
58
|
+
if (name === '@statorjs/stator') return false
|
|
59
|
+
const pkg = read(join(root, 'node_modules', name, 'package.json'))
|
|
60
|
+
return Boolean(
|
|
61
|
+
pkg &&
|
|
62
|
+
[pkg.dependencies, pkg.peerDependencies, pkg.optionalDependencies].some(
|
|
63
|
+
(deps) => deps && '@statorjs/stator' in deps,
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
40
70
|
// Load .env before anything reads process.env. No-op if the CLI already
|
|
41
71
|
// loaded it; covers a direct `createDevApp` call (a hand-written dev entry).
|
|
@@ -61,16 +91,22 @@ export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
61
91
|
// imports of a machine resolve to a `{ name }` stub (SSR loads are
|
|
62
92
|
// untouched — `options.ssr` gates it).
|
|
63
93
|
plugins: [stator(), machineStub({ machinesDir: resolve(config.machinesDir) })],
|
|
64
|
-
// The framework's own source is TS that Vite must transform, not
|
|
65
|
-
|
|
94
|
+
// The framework's own source is TS that Vite must transform, not
|
|
95
|
+
// externalize — and so must packages built on it, or they load through
|
|
96
|
+
// Node with a second Stator instance (the dual-instance trap: a
|
|
97
|
+
// component they render finds no render context).
|
|
98
|
+
ssr: { noExternal: [/@statorjs\/stator/, ...statorDependents(resolve(config.root))] },
|
|
66
99
|
logLevel: 'warn',
|
|
67
100
|
})
|
|
68
101
|
|
|
69
102
|
// Load the runtime through Vite so it shares an instance with the templates.
|
|
70
103
|
// (Type-only: the static import type is erased, so no second instance.)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
104
|
+
// Both subpaths through Vite's graph, so the plumbing shares module
|
|
105
|
+
// instances with the app code Vite loads.
|
|
106
|
+
const runtime = {
|
|
107
|
+
...(await vite.ssrLoadModule('@statorjs/stator/server')),
|
|
108
|
+
...(await vite.ssrLoadModule('@statorjs/stator/server/runtime')),
|
|
109
|
+
} as typeof import('./index.ts') & typeof import('./runtime.ts')
|
|
74
110
|
const loader = (file: string) => vite.ssrLoadModule(file) as Promise<Record<string, unknown>>
|
|
75
111
|
|
|
76
112
|
const root = resolve(config.root)
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
|
918
|
-
// during handling) and the route didn't hand-set
|
|
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')
|
package/src/server/index.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `/server`
|
|
3
|
-
*
|
|
4
|
-
* dispatchToApp,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
|
42
|
-
export type {
|
|
43
|
-
export {
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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(),
|
package/src/server/routing.ts
CHANGED
|
@@ -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.
|
|
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'
|
package/src/server/session.ts
CHANGED
|
@@ -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 {
|