@statorjs/stator 2.10.0-next.0 → 2.10.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.10.0-next.0",
3
+ "version": "2.10.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",
@@ -27,6 +27,10 @@
27
27
  "stator": "./src/cli/stator.js"
28
28
  },
29
29
  "exports": {
30
+ ".": {
31
+ "types": "./src/index.ts",
32
+ "default": "./src/index.ts"
33
+ },
30
34
  "./server": {
31
35
  "types": "./src/server/index.ts",
32
36
  "default": "./src/server/index.ts"
@@ -95,7 +99,7 @@
95
99
  "pino-pretty": "^13.1.3",
96
100
  "vite": "^6.0.0",
97
101
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.10.0-next.0"
102
+ "@statorjs/stator": "2.10.0"
99
103
  },
100
104
  "scripts": {
101
105
  "typecheck": "tsc --noEmit",
@@ -3,6 +3,7 @@ import { readdir, readFile, stat } from 'node:fs/promises'
3
3
  import { builtinModules } from 'node:module'
4
4
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
5
5
  import { build } from 'esbuild'
6
+ import ts from 'typescript'
6
7
  import { compile, regionResolverFor } from '../compiler/index.ts'
7
8
 
8
9
  /**
@@ -55,18 +56,10 @@ const NEVER_SOURCE = new Set(['node_modules', 'dist', 'tests', 'test', '__tests_
55
56
 
56
57
  const CODE = /\.(ts|tsx|mts|cts|js|mjs|cjs|jsx|stator)$/
57
58
  const BUILTIN = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)])
58
- /** `new URL('./x', import.meta.url)` — invisible to the bundler, so read the
59
- * literal directly. Same limit as the client seam: relative string literals.
60
- * (Injecting an import instead does NOT work: esbuild's TS loader elides an
61
- * import whose binding is unused, so the injected one disappears.) */
62
- const URL_ASSET_RE = /new\s+URL\(\s*(['"])(\.{1,2}\/[^'"\n]+)\1\s*,\s*import\.meta\.url\s*\)/g
63
59
  /** A `node_modules` path segment, either separator. Anything under one is a
64
60
  * dependency, never app source — and a real app's `node_modules` sits INSIDE
65
61
  * its root, so root containment alone cannot tell them apart. */
66
62
  const NODE_MODULES = /[\\/]node_modules[\\/]/
67
- /** `import(` not followed by a string or template literal. */
68
- const OPAQUE_IMPORT_RE = /(?<![.\w$])import\s*\(\s*([^'"`)\s])/g
69
-
70
63
  const norm = (p: string): string => p.replace(/\\/g, '/')
71
64
 
72
65
  export interface UntracedImport {
@@ -186,19 +179,67 @@ export async function resolveCopySet(opts: CopySetOptions): Promise<CopySet> {
186
179
  const unresolved = new Set<string>()
187
180
  const untraced: UntracedImport[] = []
188
181
 
189
- /** Record the non-import dependencies a source text carries. */
182
+ /**
183
+ * The two dependencies a module carries that the bundler's own graph misses,
184
+ * read from the SYNTAX rather than the text.
185
+ *
186
+ * Both used to be regex scans over raw source, which cannot tell code from
187
+ * prose: a comment explaining why the app avoids `import(name)`, or a string
188
+ * documenting the pattern, was picked up as a real untraceable import and
189
+ * failed the build. Found by dogfooding, and the reason this parses. Comments
190
+ * and string contents are simply not part of the tree.
191
+ */
190
192
  const scan = (text: string, file: string): string => {
191
- for (const m of text.matchAll(URL_ASSET_RE)) assets.add(resolve(dirname(file), m[2]!))
192
- for (const m of text.matchAll(OPAQUE_IMPORT_RE)) {
193
- untraced.push({
194
- file: norm(relative(realRoot, file)),
195
- line: text.slice(0, m.index).split('\n').length,
196
- source: `${text
197
- .slice(m.index, m.index + 60)
198
- .split('\n')[0]!
199
- .trim()}…`,
200
- })
193
+ const sourceFile = ts.createSourceFile(
194
+ file,
195
+ text,
196
+ ts.ScriptTarget.Latest,
197
+ false,
198
+ /\.(tsx|jsx)$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
199
+ )
200
+
201
+ /** `import.meta.url`. */
202
+ const isImportMetaUrl = (node: ts.Node): boolean =>
203
+ ts.isPropertyAccessExpression(node) &&
204
+ node.name.text === 'url' &&
205
+ ts.isMetaProperty(node.expression) &&
206
+ node.expression.keywordToken === ts.SyntaxKind.ImportKeyword
207
+
208
+ /** A specifier the bundler can follow: a string literal, or a template —
209
+ * esbuild glob-expands one with a fixed prefix, pulling in every match. */
210
+ const analysable = (node: ts.Node): boolean =>
211
+ ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)
212
+
213
+ const visit = (node: ts.Node): void => {
214
+ // `new URL('./x', import.meta.url)` — an asset reference no import graph
215
+ // sees. String-literal specifiers only, the same limit as the client seam.
216
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) {
217
+ if (node.expression.text === 'URL') {
218
+ const [spec, meta] = node.arguments ?? []
219
+ if (spec && ts.isStringLiteralLike(spec) && spec.text.startsWith('.')) {
220
+ if (meta && isImportMetaUrl(meta)) assets.add(resolve(dirname(file), spec.text))
221
+ }
222
+ }
223
+ }
224
+ // `import(expr)` the bundler cannot follow. It says nothing about these,
225
+ // so this is the only place they are caught.
226
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
227
+ const arg = node.arguments[0]
228
+ if (!arg || !analysable(arg)) {
229
+ const start = node.getStart(sourceFile)
230
+ untraced.push({
231
+ file: norm(relative(realRoot, file)),
232
+ line: sourceFile.getLineAndCharacterOfPosition(start).line + 1,
233
+ source: `${text
234
+ .slice(start, start + 60)
235
+ .split('\n')[0]!
236
+ .trim()}…`,
237
+ })
238
+ }
239
+ }
240
+ ts.forEachChild(node, visit)
201
241
  }
242
+ ts.forEachChild(sourceFile, visit)
202
243
  return text
203
244
  }
204
245
 
@@ -23,6 +23,8 @@ export async function run(ctx: CliContext): Promise<void> {
23
23
  trustedOrigins: config.trustedOrigins,
24
24
  origin: config.origin,
25
25
  cors: config.cors,
26
+ secret: config.secret,
27
+ logging: config.logging,
26
28
  })
27
29
  await app.listen(resolvePort(ctx.portFlag, config.port))
28
30
  }
@@ -95,6 +95,8 @@ export async function run(ctx: CliContext): Promise<void> {
95
95
  host: config.host,
96
96
  secret: config.secret,
97
97
  cors: config.cors,
98
+ logging: config.logging,
99
+ caching: config.caching,
98
100
  headExtras,
99
101
  buildId,
100
102
  machineHashes: machines,
@@ -277,6 +277,47 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
277
277
  }
278
278
  checkNoReadInDefer(fragment, false)
279
279
 
280
+ // Build-time gate for RCDATA elements: inside <textarea> and <title> the
281
+ // parser treats children as RAW TEXT — a live-slot <span> (text-position
282
+ // read()) or a region comment marker (each/when/match/defer) is rendered as
283
+ // literal markup instead of becoming an element. Found in the wild: a
284
+ // textarea pre-filled via read() displayed `<span data-slot="…"></span>` to
285
+ // the user. Attributes on the element itself stay legal (attribute patches
286
+ // need no wrapper) — the gate covers children only.
287
+ const RCDATA_BINDINGS = new Set(['read', 'each', 'when', 'match', 'defer'])
288
+ const checkNoBindingInRcdata = (node: ts.Node, rcdataTag: string | null): void => {
289
+ if (
290
+ rcdataTag &&
291
+ ts.isCallExpression(node) &&
292
+ ts.isIdentifier(node.expression) &&
293
+ RCDATA_BINDINGS.has(node.expression.text)
294
+ ) {
295
+ const callee = node.expression.text
296
+ throw new CompileError(
297
+ `stator: ${callee}() cannot appear inside <${rcdataTag}> — its content is raw text ` +
298
+ `(RCDATA), so the ${callee === 'read' ? 'live-slot wrapper' : 'region markers'} would ` +
299
+ `render as literal markup. Interpolate a static value instead (a selector property ` +
300
+ `like {machine.someSelector}, or a frontmatter constant); if the value must be live, ` +
301
+ `bind an attribute or use an island.`,
302
+ loc(node),
303
+ )
304
+ }
305
+ if (ts.isJsxElement(node)) {
306
+ const opening = node.openingElement
307
+ const tagName = ts.isIdentifier(opening.tagName) ? opening.tagName.text : null
308
+ const isRcdata = tagName === 'textarea' || tagName === 'title'
309
+ // Attributes keep the OUTER context; children enter the RCDATA scope.
310
+ checkNoBindingInRcdata(opening, rcdataTag)
311
+ for (const child of node.children) {
312
+ checkNoBindingInRcdata(child, isRcdata ? tagName : rcdataTag)
313
+ }
314
+ checkNoBindingInRcdata(node.closingElement, rcdataTag)
315
+ return
316
+ }
317
+ ts.forEachChild(node, (child) => checkNoBindingInRcdata(child, rcdataTag))
318
+ }
319
+ checkNoBindingInRcdata(fragment, null)
320
+
280
321
  // Build-time gate for item-read placement: a `read(<item>, …)` binding is
281
322
  // OWNED by its each() row — the row render supplies the item and collects the
282
323
  // binding, and the list's recompute is what re-diffs it. Three positions break
@@ -102,7 +102,7 @@ const STATOR_AMBIENT = `declare const Stator: {
102
102
  reads<const T extends readonly __SMachineDef[]>(defs: T): { -readonly [K in keyof T]: __SInstanceOf<T[K]> };
103
103
  forwarded(name: \`on:\${string}\`): (() => unknown) | undefined;
104
104
  request: any;
105
- response: { status: number; headers: Record<string, string>; cookies: any };
105
+ response: { status: number; headers: Headers; cookies: any };
106
106
  };
107
107
  declare global {
108
108
  namespace JSX {
package/src/config.ts CHANGED
@@ -97,6 +97,22 @@ export interface StatorConfig {
97
97
  * Opt in per route/app with the `cors()` middleware; `origins` defaults to
98
98
  * `trustedOrigins`. */
99
99
  cors?: { origins?: string[]; credentials?: boolean }
100
+ /** Derived Cache-Control (the cacheable read path, layer 3): on GET
101
+ * responses the framework can PROVE anonymous-identical — every declared
102
+ * read app-lifecycle, no session use or claims read while handling — it
103
+ * emits `Cache-Control: public, s-maxage, stale-while-revalidate` so a
104
+ * CDN can absorb anonymous traffic. Hand-set headers always win; pages
105
+ * that read session machines are never marked. On by default under
106
+ * `stator start` (60s / 300s); set to `false` to disable; dev never
107
+ * emits (editing must always re-render). */
108
+ caching?:
109
+ | {
110
+ /** Shared-cache lifetime in seconds. Default: 60. */
111
+ sMaxAge?: number
112
+ /** Serve-stale-while-refreshing window in seconds. Default: 300. */
113
+ staleWhileRevalidate?: number
114
+ }
115
+ | false
100
116
  /** Logging policy. */
101
117
  logging?: {
102
118
  /** Minimum level to emit. Default: `warn` in production, `info` in dev.
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Root export = the config-authoring surface only. `stator.config.ts` is the
2
+ // first file anyone writes against the bare package name, and it must never
3
+ // drag server-only dependencies (sharp, ioredis) into a config load — anything
4
+ // beyond config keeps its own subpath.
5
+
6
+ export type { LogLevel, StatorConfig } from './config.ts'
7
+ export { defineConfig } from './config.ts'
@@ -15,7 +15,7 @@ import type {
15
15
  Directive,
16
16
  RouteRequest,
17
17
  } from './routing.ts'
18
- import { getOrCreateSessionId, getSessionState, rotateSessionNow } from './session.ts'
18
+ import { getOrCreateSessionId, getSessionState, rotateSessionNow, sessionUse } 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,7 +76,10 @@ export async function runApiRoute(
76
76
  clearSession: () => {
77
77
  rotation = { clear: true }
78
78
  },
79
- claims: <T = unknown>() => getSessionState(c)?.claims as T | undefined,
79
+ claims: <T = unknown>() => {
80
+ sessionUse(c).claimsRead = true
81
+ return getSessionState(c)?.claims as T | undefined
82
+ },
80
83
  setClaims: (claims) => {
81
84
  const s = getSessionState(c)
82
85
  if (s) {
@@ -34,6 +34,7 @@ interface NestedReads {
34
34
  host?: string
35
35
  secret?: string
36
36
  cors?: { origins?: readonly string[]; credentials?: boolean }
37
+ caching?: { sMaxAge?: number; staleWhileRevalidate?: number } | false
37
38
  }
38
39
 
39
40
  /** The flat internal values `createApp`/`createDevApp` actually consume. */
@@ -51,6 +52,8 @@ export interface ResolvedAppConfig {
51
52
  /** Signed-cookie signing key. `config.secret` ?? `STATOR_SECRET`. */
52
53
  secret?: string
53
54
  cors?: { origins: readonly string[]; credentials: boolean }
55
+ /** Derived Cache-Control knobs; `false` disables emission entirely. */
56
+ caching?: { sMaxAge?: number; staleWhileRevalidate?: number } | false
54
57
  }
55
58
 
56
59
  /**
@@ -93,5 +96,6 @@ export function resolveAppConfig(config: NestedReads & DeprecatedFlatConfig): Re
93
96
  credentials: config.cors.credentials ?? false,
94
97
  }
95
98
  : undefined,
99
+ caching: config.caching,
96
100
  }
97
101
  }
@@ -1,6 +1,6 @@
1
1
  import type { Context } from 'hono'
2
2
  import { type CookieJar, cookieJar } from './cookies.ts'
3
- import { getSessionState, rotateSessionNow } from './session.ts'
3
+ import { getOrCreateSessionId, getSessionState, rotateSessionNow, sessionUse } from './session.ts'
4
4
  import type { Store } from './store.ts'
5
5
 
6
6
  /**
@@ -64,25 +64,34 @@ const DEFAULT: StatorConfigData = { trustedOrigins: [], sameSite: 'Lax' }
64
64
  */
65
65
  export function stator(c: Context): StatorContext {
66
66
  const cfg = c.get('stator') ?? DEFAULT
67
- const session = getSessionState(c)
68
67
  return {
69
68
  origin: cfg.origin,
70
69
  trustedOrigins: cfg.trustedOrigins,
71
70
  sameSite: cfg.sameSite,
72
71
  cors: cfg.cors,
72
+ // Live reads (not a captured snapshot): the session may be established
73
+ // mid-request, after this context object was built.
73
74
  get sid() {
74
- return session?.sid ?? ''
75
+ return getSessionState(c)?.sid ?? ''
75
76
  },
76
77
  claims<T = unknown>(): T | undefined {
77
- return session?.claims as 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
83
  },
79
84
  setClaims(claims: unknown): void {
85
+ // Writing claims is an explicit session op — an establishment trigger.
86
+ getOrCreateSessionId(c)
87
+ const session = getSessionState(c)
80
88
  if (session) {
81
89
  session.claims = claims
82
90
  session.claimsDirty = true
83
91
  }
84
92
  },
85
93
  clearClaims(): void {
94
+ const session = getSessionState(c)
86
95
  if (session) {
87
96
  session.claims = undefined
88
97
  session.claimsDirty = true
@@ -89,6 +89,9 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
89
89
  * keyed by file relative to `machinesDir`. Omitted ⇒ hashed live at boot;
90
90
  * a discovered machine missing from a supplied map is a boot error. */
91
91
  machineHashes?: Readonly<Record<string, string>>
92
+ /** Derived Cache-Control (cacheable read path, layer 3). Defaults on with
93
+ * modest values (60s / 300s); `false` disables emission. */
94
+ caching?: { sMaxAge?: number; staleWhileRevalidate?: number } | false
92
95
  }
93
96
 
94
97
  export interface StatorApp {
@@ -163,6 +166,16 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
163
166
  buildId: config.buildId,
164
167
  cors: resolved.cors,
165
168
  middleware,
169
+ // Layer-3 derived Cache-Control: on by default with modest values; the
170
+ // whole block set to `false` disables. (createDevApp never passes it —
171
+ // dev pages must always re-render while editing.)
172
+ caching:
173
+ resolved.caching === false
174
+ ? undefined
175
+ : {
176
+ sMaxAge: resolved.caching?.sMaxAge ?? 60,
177
+ staleWhileRevalidate: resolved.caching?.staleWhileRevalidate ?? 300,
178
+ },
166
179
  })
167
180
 
168
181
  return {