@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 +6 -2
- package/src/build/copy-set.ts +60 -19
- package/src/cli/commands/dev.ts +2 -0
- package/src/cli/commands/start.ts +2 -0
- package/src/compiler/lower.ts +41 -0
- package/src/compiler/virtual-code.ts +1 -1
- package/src/config.ts +16 -0
- package/src/index.ts +7 -0
- package/src/server/api-route.ts +5 -2
- package/src/server/config-compat.ts +4 -0
- package/src/server/context.ts +13 -4
- package/src/server/create-app.ts +13 -0
- package/src/server/http.ts +284 -97
- package/src/server/query-route.ts +68 -3
- package/src/server/route-discovery.ts +36 -5
- package/src/server/routing.ts +8 -0
- package/src/server/session.ts +58 -11
- package/src/server/sse.ts +195 -38
- package/src/template/html-attributes.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.10.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
|
|
102
|
+
"@statorjs/stator": "2.10.0"
|
|
99
103
|
},
|
|
100
104
|
"scripts": {
|
|
101
105
|
"typecheck": "tsc --noEmit",
|
package/src/build/copy-set.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -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
|
}
|
package/src/compiler/lower.ts
CHANGED
|
@@ -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:
|
|
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'
|
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, 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>() =>
|
|
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
|
}
|
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 { 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
|
|
75
|
+
return getSessionState(c)?.sid ?? ''
|
|
75
76
|
},
|
|
76
77
|
claims<T = unknown>(): T | undefined {
|
|
77
|
-
|
|
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
|
package/src/server/create-app.ts
CHANGED
|
@@ -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 {
|