@statorjs/stator 1.0.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/package.json +99 -0
  4. package/src/build/build.ts +244 -0
  5. package/src/build/head.ts +43 -0
  6. package/src/build/index.ts +10 -0
  7. package/src/build/sync.ts +65 -0
  8. package/src/client/bind.ts +47 -0
  9. package/src/client/client-id.ts +10 -0
  10. package/src/client/dispatch.ts +62 -0
  11. package/src/client/element.ts +156 -0
  12. package/src/client/index.ts +13 -0
  13. package/src/client/inspector.ts +237 -0
  14. package/src/client/machine.ts +39 -0
  15. package/src/client/runtime.ts +246 -0
  16. package/src/client/use.ts +119 -0
  17. package/src/compiler/client-emit.ts +180 -0
  18. package/src/compiler/client-script.ts +256 -0
  19. package/src/compiler/compile.ts +459 -0
  20. package/src/compiler/diagnostics.ts +65 -0
  21. package/src/compiler/dts.ts +87 -0
  22. package/src/compiler/hash.ts +8 -0
  23. package/src/compiler/index.ts +48 -0
  24. package/src/compiler/lower.ts +0 -0
  25. package/src/compiler/regions.ts +79 -0
  26. package/src/compiler/split.ts +168 -0
  27. package/src/compiler/styles.ts +200 -0
  28. package/src/compiler/virtual-code.ts +184 -0
  29. package/src/components/index.ts +2 -0
  30. package/src/components/json-ld.ts +85 -0
  31. package/src/engine/actor.ts +248 -0
  32. package/src/engine/define-machine.ts +113 -0
  33. package/src/engine/index.ts +37 -0
  34. package/src/engine/types.ts +236 -0
  35. package/src/server/api-route.ts +149 -0
  36. package/src/server/app-dispatch.ts +52 -0
  37. package/src/server/app-store.ts +35 -0
  38. package/src/server/cached-store.ts +117 -0
  39. package/src/server/create-app.ts +83 -0
  40. package/src/server/define-machine.ts +10 -0
  41. package/src/server/dev.ts +255 -0
  42. package/src/server/discovery.ts +111 -0
  43. package/src/server/dispatch-context.ts +39 -0
  44. package/src/server/effects.ts +120 -0
  45. package/src/server/http.ts +475 -0
  46. package/src/server/index.ts +101 -0
  47. package/src/server/instance-proxy.ts +95 -0
  48. package/src/server/logger.ts +52 -0
  49. package/src/server/machine-store.ts +355 -0
  50. package/src/server/reads-helpers.ts +40 -0
  51. package/src/server/recompute.ts +287 -0
  52. package/src/server/redis-store.ts +116 -0
  53. package/src/server/render-context.ts +228 -0
  54. package/src/server/render.ts +111 -0
  55. package/src/server/route-discovery.ts +226 -0
  56. package/src/server/route-request.ts +36 -0
  57. package/src/server/routing.ts +175 -0
  58. package/src/server/session-lock.ts +33 -0
  59. package/src/server/session-runtime.ts +242 -0
  60. package/src/server/session.ts +29 -0
  61. package/src/server/sse.ts +175 -0
  62. package/src/server/store.ts +95 -0
  63. package/src/stator-modules.d.ts +12 -0
  64. package/src/template/client-shell.ts +65 -0
  65. package/src/template/conditional.ts +166 -0
  66. package/src/template/directives/core.ts +58 -0
  67. package/src/template/directives/list-attr.ts +183 -0
  68. package/src/template/directives/on.ts +22 -0
  69. package/src/template/each.ts +295 -0
  70. package/src/template/html.ts +180 -0
  71. package/src/template/index.ts +29 -0
  72. package/src/template/parser.ts +341 -0
  73. package/src/template/read.ts +50 -0
  74. package/src/template/types.ts +33 -0
  75. package/src/vite/index.ts +6 -0
  76. package/src/vite/plugin.ts +151 -0
  77. package/src/vite/stub.ts +79 -0
  78. package/src/wire/apply.ts +103 -0
  79. package/src/wire/index.ts +67 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tony Sullivan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # @statorjs/stator
2
+
3
+ A server-canonical web framework: business logic lives in composable state
4
+ machines that have no awareness of the UI, and the UI is a thin renderer
5
+ binding machine outputs to DOM positions. Templates are `.stator` single-file
6
+ components; interactions POST typed events; the server diffs affected bindings
7
+ into small JSON patches. Client islands run the same machine engine in the
8
+ browser as custom elements.
9
+
10
+ Full documentation — tutorial, concepts, guides — lives in the repo's
11
+ [`apps/docs`](https://github.com/statorjs/stator/tree/main/apps/docs) site;
12
+ the repo [README](https://github.com/statorjs/stator#readme) has the tour.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pnpm add @statorjs/stator hono
18
+ ```
19
+
20
+ > **This package ships TypeScript source, by design.** Stator is
21
+ > Vite/tsx-native: the dev server compiles `.stator` (and the framework's own
22
+ > `.ts`) through Vite, and production runs the built output under `tsx`. There
23
+ > is no `dist/` of transpiled JS — a plain-Node consumer that can't load `.ts`
24
+ > modules can't import this package directly. This is a deliberate 1.0 stance,
25
+ > not an oversight.
26
+
27
+ ## Entry points
28
+
29
+ | import | contents |
30
+ |---|---|
31
+ | `@statorjs/stator/server` | `createApp`, `defineMachine`, `defineRoute`, `defineApiRoute`, stores, `dispatchToApp` |
32
+ | `@statorjs/stator/machine` | the browser-safe engine core (`defineMachine`, `createActor`) |
33
+ | `@statorjs/stator/template` | `html`, `read`, `each`, `when`/`match`, `raw`, directives |
34
+ | `@statorjs/stator/client` | island runtime — `StatorElement`, `use`, `machine`, `bind`, `dispatch` |
35
+ | `@statorjs/stator/dev` | `createDevApp` — the Vite-embedded dev server |
36
+ | `@statorjs/stator/build` | `buildApp`, `loadProductionHead`, `syncTypes` |
37
+ | `@statorjs/stator/components` | built-ins (`<JsonLd>`) |
38
+
39
+ `compiler` and `vite` subpaths exist but are internal — their shape may change
40
+ in minor releases.
41
+
42
+ ## Minimal app
43
+
44
+ ```ts
45
+ // server.ts
46
+ import { dirname, resolve } from 'node:path'
47
+ import { fileURLToPath } from 'node:url'
48
+ import { createDevApp } from '@statorjs/stator/dev'
49
+
50
+ const here = dirname(fileURLToPath(import.meta.url))
51
+ const app = await createDevApp({
52
+ root: here,
53
+ machinesDir: resolve(here, 'machines'),
54
+ routesDir: resolve(here, 'routes'),
55
+ })
56
+ await app.listen(3000)
57
+ ```
58
+
59
+ ```ts
60
+ // machines/counter.ts
61
+ import { defineMachine } from '@statorjs/stator/server'
62
+
63
+ type Events = { type: 'INCREMENT' }
64
+
65
+ export default defineMachine({
66
+ name: 'CounterMachine',
67
+ lifecycle: 'session',
68
+ events: {} as Events,
69
+ context: { count: 0 },
70
+ initial: 'idle',
71
+ states: {
72
+ idle: {
73
+ on: {
74
+ INCREMENT: (ctx) => {
75
+ ctx.count += 1
76
+ },
77
+ },
78
+ },
79
+ },
80
+ selectors: { label: (ctx) => `count is ${ctx.count}` },
81
+ })
82
+ ```
83
+
84
+ ```
85
+ // routes/index.stator (`Stator` is provided by the compiler — no import)
86
+ ---
87
+ import Counter from '../machines/counter.ts'
88
+
89
+ const [counter] = Stator.reads([Counter])
90
+ ---
91
+ <html>
92
+ <body>
93
+ <p>{read(counter, (c) => c.label)}</p>
94
+ <button on:click={() => counter.send({ type: 'INCREMENT' })}>+</button>
95
+ </body>
96
+ </html>
97
+ ```
98
+
99
+ Run with `tsx server.ts`. See the repo's `apps/example` for the full wiring
100
+ (type sync, production build, deploy).
101
+
102
+ ## License
103
+
104
+ MIT
package/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "@statorjs/stator",
3
+ "version": "1.0.0",
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 (Vite/tsx-native by design).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/statorjs/stator.git",
10
+ "directory": "packages/stator"
11
+ },
12
+ "homepage": "https://github.com/statorjs/stator#readme",
13
+ "bugs": "https://github.com/statorjs/stator/issues",
14
+ "keywords": [
15
+ "framework",
16
+ "server-canonical",
17
+ "state-machine",
18
+ "ssr",
19
+ "sse",
20
+ "islands",
21
+ "vite"
22
+ ],
23
+ "files": [
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "exports": {
28
+ "./server": {
29
+ "types": "./src/server/index.ts",
30
+ "default": "./src/server/index.ts"
31
+ },
32
+ "./machine": {
33
+ "types": "./src/engine/index.ts",
34
+ "default": "./src/engine/index.ts"
35
+ },
36
+ "./dev": {
37
+ "types": "./src/server/dev.ts",
38
+ "default": "./src/server/dev.ts"
39
+ },
40
+ "./build": {
41
+ "types": "./src/build/index.ts",
42
+ "default": "./src/build/index.ts"
43
+ },
44
+ "./template": {
45
+ "types": "./src/template/index.ts",
46
+ "default": "./src/template/index.ts"
47
+ },
48
+ "./components": {
49
+ "types": "./src/components/index.ts",
50
+ "default": "./src/components/index.ts"
51
+ },
52
+ "./client": {
53
+ "types": "./src/client/index.ts",
54
+ "default": "./src/client/index.ts"
55
+ },
56
+ "./compiler": {
57
+ "types": "./src/compiler/index.ts",
58
+ "default": "./src/compiler/index.ts"
59
+ },
60
+ "./vite": {
61
+ "types": "./src/vite/index.ts",
62
+ "default": "./src/vite/index.ts"
63
+ }
64
+ },
65
+ "dependencies": {
66
+ "@hono/node-server": "^1.13.0",
67
+ "esbuild": "^0.24.0",
68
+ "hono": "^4.6.0",
69
+ "ioredis": "^5.10.1",
70
+ "pino": "^10.3.1",
71
+ "postcss": "^8.5.15",
72
+ "postcss-selector-parser": "^7.1.4",
73
+ "schema-dts": "^1.1.5",
74
+ "typescript": "^5.6.0",
75
+ "zod": "^3.23.0"
76
+ },
77
+ "peerDependencies": {
78
+ "vite": "^6.0.0 || ^7.0.0"
79
+ },
80
+ "peerDependenciesMeta": {
81
+ "vite": {
82
+ "optional": true
83
+ }
84
+ },
85
+ "devDependencies": {
86
+ "@types/node": "^22.7.0",
87
+ "happy-dom": "^15.0.0",
88
+ "pino-pretty": "^13.1.3",
89
+ "vite": "^6.0.0",
90
+ "vitest": "^2.1.0",
91
+ "@statorjs/stator": "1.0.0"
92
+ },
93
+ "scripts": {
94
+ "typecheck": "tsc --noEmit",
95
+ "test": "vitest run",
96
+ "test:watch": "vitest",
97
+ "build:client": "esbuild src/client/runtime.ts --bundle --format=iife --target=es2020 --outfile=dist/client.js"
98
+ }
99
+ }
@@ -0,0 +1,244 @@
1
+ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
2
+ import { join, relative, resolve, sep } from 'node:path'
3
+ import { compile } from '../compiler/index.ts'
4
+
5
+ /**
6
+ * Production build: compile a `.stator` app to a `dist/` of plain `.ts` that the
7
+ * existing `createApp` + tsx runtime serves with no Vite.
8
+ *
9
+ * 1. copy machines / routes / templates / static into dist
10
+ * 2. compile each `*.stator` → a sibling `*.stator.ts`, delete the `.stator`,
11
+ * accumulate scoped CSS; for client components also write the generated
12
+ * client entry as a sibling `*.stator.client.ts`
13
+ * 3. rewrite `.stator` import specifiers (`'./x.stator'` → `'./x.stator.ts'`)
14
+ * 4. write the concatenated scoped CSS to `dist/static/components.css`
15
+ * 5. when the app has client components: bundle every island entry in one
16
+ * Vite build (hashed output under `dist/static/assets/`, server-machine
17
+ * imports stubbed to `{ name }` via `machineStub`), walk each route's
18
+ * import graph to find which islands it reaches, and write
19
+ * `dist/stator-manifest.json` mapping route files → island script URLs
20
+ *
21
+ * The prod server runs `createApp` over `dist/` with the `headExtras` hook
22
+ * from `loadProductionHead(dist)` — it links `components.css` and injects the
23
+ * manifest's `<script type="module">` tags per route. File discovery + dynamic
24
+ * import work unchanged on the precompiled output; Vite is needed only at
25
+ * build time, and only when islands exist.
26
+ */
27
+
28
+ export interface BuildConfig {
29
+ /** App directory containing machines/ routes/ templates/ static/. */
30
+ root: string
31
+ /** Output directory. Wiped and recreated. */
32
+ outDir: string
33
+ /** Subdirectories to copy into dist. Defaults to every top-level directory
34
+ * in the app root except node_modules, tests, hidden dirs, and the outDir
35
+ * itself — machines and routes import freely from sibling dirs (lib/,
36
+ * data/), so dist must mirror the app's source shape. */
37
+ dirs?: string[]
38
+ }
39
+
40
+ export interface BuildResult {
41
+ outDir: string
42
+ /** Number of `.stator` files compiled. */
43
+ compiled: number
44
+ /** True when any component produced scoped CSS (components.css written). */
45
+ hasCss: boolean
46
+ /** Number of client components bundled for the browser. */
47
+ islands: number
48
+ }
49
+
50
+ /** Shape of `dist/stator-manifest.json` (only written when islands exist). */
51
+ export interface StatorManifest {
52
+ /** Island component (dist-relative `.stator` path) → its script URL. */
53
+ islands: Record<string, string>
54
+ /** Route file (dist-relative) → script URLs for every island it reaches. */
55
+ routes: Record<string, string[]>
56
+ }
57
+
58
+ const NEVER_COPY = new Set(['node_modules', 'tests', 'test', '__tests__'])
59
+
60
+ /** Every top-level directory that can hold app source. Machines/routes
61
+ * import from arbitrary sibling dirs, so dist mirrors the source tree. */
62
+ async function discoverSourceDirs(root: string, outDir: string): Promise<string[]> {
63
+ const outBase = relative(root, outDir).split(sep)[0]
64
+ const entries = await readdir(root, { withFileTypes: true })
65
+ return entries
66
+ .filter(
67
+ (e) =>
68
+ e.isDirectory() && !e.name.startsWith('.') && !NEVER_COPY.has(e.name) && e.name !== outBase,
69
+ )
70
+ .map((e) => e.name)
71
+ }
72
+
73
+ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
74
+ const root = resolve(config.root)
75
+ const outDir = resolve(config.outDir)
76
+ const dirs = config.dirs ?? (await discoverSourceDirs(root, outDir))
77
+
78
+ await rm(outDir, { recursive: true, force: true })
79
+ await mkdir(outDir, { recursive: true })
80
+ for (const d of dirs) {
81
+ const src = join(root, d)
82
+ if (await exists(src)) await cp(src, join(outDir, d), { recursive: true })
83
+ }
84
+
85
+ // Compile every .stator into a sibling .stator.ts; collect CSS and islands.
86
+ const statorFiles = await walk(outDir, (f) => f.endsWith('.stator'))
87
+ let css = ''
88
+ const islands: Array<{ rel: string; entry: string }> = []
89
+ for (const file of statorFiles) {
90
+ const source = await readFile(file, 'utf8')
91
+ const rel = relative(outDir, file)
92
+ const kind = /(^|[\\/])routes[\\/]/.test(rel) ? ('route' as const) : ('component' as const)
93
+ const result = compile(source, { id: rel, kind })
94
+ await writeFile(`${file}.ts`, result.serverCode)
95
+ if (result.isClient) {
96
+ // The generated client entry, written as a sibling so the authored
97
+ // script's relative imports resolve against the mirrored dist tree.
98
+ await writeFile(`${file}.client.ts`, result.clientCode)
99
+ islands.push({ rel, entry: `${file}.client.ts` })
100
+ }
101
+ await rm(file)
102
+ if (result.css) css += `/* ${rel} */\n${result.css}\n`
103
+ }
104
+
105
+ // Rewrite `.stator` import specifiers to the compiled `.stator.ts` sibling.
106
+ const tsFiles = await walk(outDir, (f) => f.endsWith('.ts'))
107
+ for (const file of tsFiles) {
108
+ const code = await readFile(file, 'utf8')
109
+ const rewritten = code.replace(/(['"])([^'"]+\.stator)\1/g, '$1$2.ts$1')
110
+ if (rewritten !== code) await writeFile(file, rewritten)
111
+ }
112
+
113
+ if (css) {
114
+ await mkdir(join(outDir, 'static'), { recursive: true })
115
+ await writeFile(join(outDir, 'static', 'components.css'), css)
116
+ }
117
+
118
+ if (islands.length > 0) {
119
+ const manifest = await buildClientAssets(outDir, islands)
120
+ await writeFile(join(outDir, 'stator-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
121
+ }
122
+
123
+ return { outDir, compiled: statorFiles.length, hasCss: Boolean(css), islands: islands.length }
124
+ }
125
+
126
+ /**
127
+ * Bundle every island entry in one Vite build and derive the manifest.
128
+ * Vite is imported lazily — server-only apps never need it at build time.
129
+ */
130
+ async function buildClientAssets(
131
+ outDir: string,
132
+ islands: Array<{ rel: string; entry: string }>,
133
+ ): Promise<StatorManifest> {
134
+ const [{ build: viteBuild }, { machineStub }] = await Promise.all([
135
+ import('vite'),
136
+ import('../vite/stub.ts'),
137
+ ])
138
+
139
+ const input: Record<string, string> = {}
140
+ for (const island of islands) {
141
+ input[island.rel.replace(/\.stator$/, '').replace(/[\\/]/g, '_')] = island.entry
142
+ }
143
+
144
+ const assetsDir = join(outDir, 'static', 'assets')
145
+ await viteBuild({
146
+ root: outDir,
147
+ logLevel: 'warn',
148
+ configFile: false,
149
+ plugins: [machineStub({ machinesDir: join(outDir, 'machines') })],
150
+ build: {
151
+ outDir: assetsDir,
152
+ emptyOutDir: true,
153
+ manifest: true,
154
+ rollupOptions: {
155
+ input,
156
+ output: {
157
+ entryFileNames: '[name]-[hash].js',
158
+ chunkFileNames: 'chunks/[name]-[hash].js',
159
+ assetFileNames: '[name]-[hash][extname]',
160
+ },
161
+ },
162
+ },
163
+ })
164
+
165
+ // Vite's manifest keys inputs by root-relative path; map back to islands.
166
+ const viteManifest = JSON.parse(
167
+ await readFile(join(assetsDir, '.vite', 'manifest.json'), 'utf8'),
168
+ ) as Record<string, { file: string; isEntry?: boolean }>
169
+ const islandUrls: Record<string, string> = {}
170
+ for (const island of islands) {
171
+ const key = relative(outDir, island.entry).replace(/\\/g, '/')
172
+ const entry = viteManifest[key]
173
+ if (!entry) throw new Error(`stator: island "${island.rel}" missing from Vite manifest`)
174
+ islandUrls[island.rel] = `/static/assets/${entry.file}`
175
+ }
176
+
177
+ // Per-route reachability: walk each route file's relative-import graph
178
+ // (post-rewrite, so island shells appear as `<island>.ts`) and record which
179
+ // islands it reaches. Mirrors the dev server's module-graph walk.
180
+ const shellToIsland = new Map(islands.map((i) => [resolve(outDir, `${i.rel}.ts`), i.rel]))
181
+ const routeFiles = await walk(join(outDir, 'routes'), (f) => f.endsWith('.ts'))
182
+ const routes: Record<string, string[]> = {}
183
+ for (const routeFile of routeFiles) {
184
+ const reached = new Set<string>()
185
+ await walkImports(routeFile, outDir, new Set(), (file) => {
186
+ const island = shellToIsland.get(file)
187
+ if (island) reached.add(island)
188
+ })
189
+ if (reached.size > 0) {
190
+ routes[relative(outDir, routeFile).replace(/\\/g, '/')] = [...reached]
191
+ .sort()
192
+ .map((rel) => islandUrls[rel]!)
193
+ }
194
+ }
195
+
196
+ return { islands: islandUrls, routes }
197
+ }
198
+
199
+ const IMPORT_SPECIFIER_RE = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g
200
+
201
+ /** Depth-first walk of a file's relative-import graph, bounded to `outDir`. */
202
+ async function walkImports(
203
+ file: string,
204
+ outDir: string,
205
+ seen: Set<string>,
206
+ visit: (file: string) => void,
207
+ ): Promise<void> {
208
+ if (seen.has(file)) return
209
+ seen.add(file)
210
+ visit(file)
211
+ let code: string
212
+ try {
213
+ code = await readFile(file, 'utf8')
214
+ } catch {
215
+ return
216
+ }
217
+ for (const match of code.matchAll(IMPORT_SPECIFIER_RE)) {
218
+ const spec = match[1]!
219
+ if (!spec.startsWith('.')) continue
220
+ const target = resolve(join(file, '..'), spec)
221
+ if (!target.startsWith(outDir)) continue
222
+ await walkImports(target, outDir, seen, visit)
223
+ }
224
+ }
225
+
226
+ async function exists(path: string): Promise<boolean> {
227
+ try {
228
+ await stat(path)
229
+ return true
230
+ } catch {
231
+ return false
232
+ }
233
+ }
234
+
235
+ async function walk(dir: string, match: (file: string) => boolean): Promise<string[]> {
236
+ const out: string[] = []
237
+ const entries = await readdir(dir, { withFileTypes: true })
238
+ for (const e of entries) {
239
+ const full = join(dir, e.name)
240
+ if (e.isDirectory()) out.push(...(await walk(full, match)))
241
+ else if (match(full)) out.push(full)
242
+ }
243
+ return out
244
+ }
@@ -0,0 +1,43 @@
1
+ import { readFile, stat } from 'node:fs/promises'
2
+ import { join, relative, resolve } from 'node:path'
3
+ import type { StatorManifest } from './build.ts'
4
+
5
+ /**
6
+ * Production `headExtras` for a built `dist/`: links `components.css` when the
7
+ * build produced one, and injects each route's island `<script type="module">`
8
+ * tags from `stator-manifest.json`. Pass the result to `createApp`:
9
+ *
10
+ * const app = await createApp({ ..., headExtras: await loadProductionHead(dist) })
11
+ *
12
+ * Both artifacts are optional — a server-only app without styles gets an
13
+ * empty hook.
14
+ */
15
+ export async function loadProductionHead(distDir: string): Promise<(filePath: string) => string> {
16
+ const dist = resolve(distDir)
17
+
18
+ let cssTag = ''
19
+ try {
20
+ await stat(join(dist, 'static', 'components.css'))
21
+ cssTag = '<link rel="stylesheet" href="/static/components.css">'
22
+ } catch {
23
+ // no scoped component styles
24
+ }
25
+
26
+ let routes: StatorManifest['routes'] = {}
27
+ try {
28
+ const manifest = JSON.parse(
29
+ await readFile(join(dist, 'stator-manifest.json'), 'utf8'),
30
+ ) as StatorManifest
31
+ routes = manifest.routes ?? {}
32
+ } catch {
33
+ // no islands
34
+ }
35
+
36
+ return (filePath: string): string => {
37
+ const rel = relative(dist, resolve(filePath)).replace(/\\/g, '/')
38
+ const scripts = routes[rel] ?? []
39
+ return [cssTag, ...scripts.map((url) => `<script type="module" src="${url}"></script>`)]
40
+ .filter(Boolean)
41
+ .join('\n')
42
+ }
43
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Production build for `.stator` apps. Compiles a `.stator` app to a `dist/` of
3
+ * plain `.ts` the runtime serves with no Vite. See `buildApp`.
4
+ */
5
+
6
+ export type { BuildConfig, BuildResult, StatorManifest } from './build.ts'
7
+ export { buildApp } from './build.ts'
8
+ export { loadProductionHead } from './head.ts'
9
+ export type { SyncResult } from './sync.ts'
10
+ export { syncTypes } from './sync.ts'
@@ -0,0 +1,65 @@
1
+ import type { Dirent } from 'node:fs'
2
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { dirname, join, relative, sep } from 'node:path'
4
+ import { generateDts } from '../compiler/dts.ts'
5
+
6
+ /**
7
+ * Type sync: generate a `<name>.stator.d.ts` for each component so editors and
8
+ * `tsc` type `import X from './x.stator'` against the component's real props
9
+ * (the `.d.ts` beats the `*.stator` ambient wildcard).
10
+ *
11
+ * The generated files live in a hidden, framework-managed `.stator/types/`
12
+ * directory that MIRRORS the source tree — never next to source. TS finds them
13
+ * via `rootDirs: ['.', '.stator/types']` in the app's tsconfig, which merges the
14
+ * two trees into one virtual root (the Astro `.astro/` / SvelteKit `.svelte-kit/`
15
+ * convention). `.stator/` is gitignored.
16
+ *
17
+ * Route pages (`routes/*.stator`) are skipped — they export `GET`, not a render
18
+ * function.
19
+ */
20
+ export interface SyncResult {
21
+ /** Number of `.stator.d.ts` files written. */
22
+ written: number
23
+ /** The generated-types directory. */
24
+ outDir: string
25
+ }
26
+
27
+ const TYPES_DIR = join('.stator', 'types')
28
+
29
+ export async function syncTypes(root: string): Promise<SyncResult> {
30
+ const outDir = join(root, TYPES_DIR)
31
+ await rm(outDir, { recursive: true, force: true })
32
+
33
+ const files = await walk(root)
34
+ let written = 0
35
+ for (const file of files) {
36
+ const kind = file.split(sep).includes('routes') ? 'route' : 'component'
37
+ const dts = generateDts(await readFile(file, 'utf8'), { kind })
38
+ if (dts === null) continue
39
+ // Mirror the source path under .stator/types: templates/x.stator →
40
+ // .stator/types/templates/x.stator.d.ts.
41
+ const rel = relative(root, file)
42
+ const target = join(outDir, `${rel}.d.ts`)
43
+ await mkdir(dirname(target), { recursive: true })
44
+ await writeFile(target, dts)
45
+ written++
46
+ }
47
+ return { written, outDir }
48
+ }
49
+
50
+ async function walk(dir: string): Promise<string[]> {
51
+ const out: string[] = []
52
+ let entries: Dirent[]
53
+ try {
54
+ entries = await readdir(dir, { withFileTypes: true })
55
+ } catch {
56
+ return out
57
+ }
58
+ for (const e of entries) {
59
+ if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue
60
+ const full = join(dir, e.name)
61
+ if (e.isDirectory()) out.push(...(await walk(full)))
62
+ else if (e.name.endsWith('.stator')) out.push(full)
63
+ }
64
+ return out
65
+ }
@@ -0,0 +1,47 @@
1
+ import { actorOf, type ClientInstance } from './use.ts'
2
+
3
+ /**
4
+ * The one client binding mechanism: subscribe to a set of client actors, and on
5
+ * any change re-evaluate a value thunk, diff against the last value, and write
6
+ * the DOM. The client mirror of the server's recompute loop.
7
+ *
8
+ * The compiler generates one `bind()` call per `bind:` directive: it infers the
9
+ * dependency set (the `use()` instances the expression references), passes the
10
+ * expression as the thunk, and supplies the target node + write function.
11
+ *
12
+ * Returns a disposer that unsubscribes.
13
+ */
14
+ export function bind(
15
+ deps: ClientInstance[],
16
+ compute: () => unknown,
17
+ apply: (value: unknown) => void,
18
+ ): () => void {
19
+ let last = compute()
20
+ apply(last)
21
+
22
+ const onChange = (): void => {
23
+ const next = compute()
24
+ if (!Object.is(next, last)) {
25
+ last = next
26
+ apply(next)
27
+ }
28
+ }
29
+
30
+ const unsubs = deps.map((d) => actorOf(d).subscribe(onChange).unsubscribe)
31
+ return () => {
32
+ for (const u of unsubs) u()
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Imperative reactivity escape hatch: run `fn` now and again whenever any
38
+ * dependency changes (no diffing — `fn` owns its own DOM writes). The lower-
39
+ * level primitive `{key}Changed` desugars to. Returns a disposer.
40
+ */
41
+ export function effect(deps: ClientInstance[], fn: () => void): () => void {
42
+ fn()
43
+ const unsubs = deps.map((d) => actorOf(d).subscribe(fn).unsubscribe)
44
+ return () => {
45
+ for (const u of unsubs) u()
46
+ }
47
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Per-page-load identity, sent on the SSE connect AND on every dispatch —
3
+ * fan-out uses it to recognize a dispatch's own connection and advance its
4
+ * diff baseline WITHOUT re-sending patches the POST response already
5
+ * delivered (text/attr dupes are invisible; keyed inserts are not).
6
+ */
7
+ export const clientId: string =
8
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
9
+ ? crypto.randomUUID()
10
+ : `c${Math.random().toString(36).slice(2)}`