@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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +99 -0
- package/src/build/build.ts +244 -0
- package/src/build/head.ts +43 -0
- package/src/build/index.ts +10 -0
- package/src/build/sync.ts +65 -0
- package/src/client/bind.ts +47 -0
- package/src/client/client-id.ts +10 -0
- package/src/client/dispatch.ts +62 -0
- package/src/client/element.ts +156 -0
- package/src/client/index.ts +13 -0
- package/src/client/inspector.ts +237 -0
- package/src/client/machine.ts +39 -0
- package/src/client/runtime.ts +246 -0
- package/src/client/use.ts +119 -0
- package/src/compiler/client-emit.ts +180 -0
- package/src/compiler/client-script.ts +256 -0
- package/src/compiler/compile.ts +459 -0
- package/src/compiler/diagnostics.ts +65 -0
- package/src/compiler/dts.ts +87 -0
- package/src/compiler/hash.ts +8 -0
- package/src/compiler/index.ts +48 -0
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/regions.ts +79 -0
- package/src/compiler/split.ts +168 -0
- package/src/compiler/styles.ts +200 -0
- package/src/compiler/virtual-code.ts +184 -0
- package/src/components/index.ts +2 -0
- package/src/components/json-ld.ts +85 -0
- package/src/engine/actor.ts +248 -0
- package/src/engine/define-machine.ts +113 -0
- package/src/engine/index.ts +37 -0
- package/src/engine/types.ts +236 -0
- package/src/server/api-route.ts +149 -0
- package/src/server/app-dispatch.ts +52 -0
- package/src/server/app-store.ts +35 -0
- package/src/server/cached-store.ts +117 -0
- package/src/server/create-app.ts +83 -0
- package/src/server/define-machine.ts +10 -0
- package/src/server/dev.ts +255 -0
- package/src/server/discovery.ts +111 -0
- package/src/server/dispatch-context.ts +39 -0
- package/src/server/effects.ts +120 -0
- package/src/server/http.ts +475 -0
- package/src/server/index.ts +101 -0
- package/src/server/instance-proxy.ts +95 -0
- package/src/server/logger.ts +52 -0
- package/src/server/machine-store.ts +355 -0
- package/src/server/reads-helpers.ts +40 -0
- package/src/server/recompute.ts +287 -0
- package/src/server/redis-store.ts +116 -0
- package/src/server/render-context.ts +228 -0
- package/src/server/render.ts +111 -0
- package/src/server/route-discovery.ts +226 -0
- package/src/server/route-request.ts +36 -0
- package/src/server/routing.ts +175 -0
- package/src/server/session-lock.ts +33 -0
- package/src/server/session-runtime.ts +242 -0
- package/src/server/session.ts +29 -0
- package/src/server/sse.ts +175 -0
- package/src/server/store.ts +95 -0
- package/src/stator-modules.d.ts +12 -0
- package/src/template/client-shell.ts +65 -0
- package/src/template/conditional.ts +166 -0
- package/src/template/directives/core.ts +58 -0
- package/src/template/directives/list-attr.ts +183 -0
- package/src/template/directives/on.ts +22 -0
- package/src/template/each.ts +295 -0
- package/src/template/html.ts +180 -0
- package/src/template/index.ts +29 -0
- package/src/template/parser.ts +341 -0
- package/src/template/read.ts +50 -0
- package/src/template/types.ts +33 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/plugin.ts +151 -0
- package/src/vite/stub.ts +79 -0
- package/src/wire/apply.ts +103 -0
- 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)}`
|