@statorjs/stator 2.7.0 → 2.8.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.7.0",
3
+ "version": "2.8.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",
@@ -95,7 +95,7 @@
95
95
  "pino-pretty": "^13.1.3",
96
96
  "vite": "^6.0.0",
97
97
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.7.0"
98
+ "@statorjs/stator": "2.8.0"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,175 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import type { BundleIslandsOptions, IslandAsset, IslandBundler } from './islands.ts'
5
+
6
+ /**
7
+ * Implementation #2 of the island-bundling seam (spike for Option E of the
8
+ * Vite exit): one esbuild build over every island entry, `splitting: true` so
9
+ * shared modules — the client runtime above all — land in shared chunks
10
+ * instead of duplicating per island.
11
+ *
12
+ * Same contract as the Vite implementation in `islands.ts`: entries are
13
+ * `.stator` sources (compiled to their client module here) or prebuilt client
14
+ * entry modules; server-machine imports collapse to `{ name }` stubs; nothing
15
+ * is written — callers own the assets.
16
+ */
17
+
18
+ const norm = (p: string): string => p.replace(/\\/g, '/')
19
+
20
+ const STUB_NS = 'stator-machine-stub'
21
+
22
+ /** Extensions an island may reference by URL; emitted as hashed files (the
23
+ * seam contract — never inlined), mirroring Vite's `assetsInlineLimit: 0`. */
24
+ const FILE_LOADERS: Record<string, 'file'> = Object.fromEntries(
25
+ ['.wasm', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.woff', '.woff2'].map((e) => [
26
+ e,
27
+ 'file' as const,
28
+ ]),
29
+ )
30
+
31
+ export const bundleIslandsEsbuild: IslandBundler = async (opts: BundleIslandsOptions) => {
32
+ const [{ build }, { compile, regionResolverFor }] = await Promise.all([
33
+ import('esbuild'),
34
+ import('../compiler/index.ts'),
35
+ ])
36
+ const publicPath = opts.publicPath ?? '/static/assets/'
37
+ const root = resolve(opts.root)
38
+ const machinesDir = resolve(opts.machinesDir)
39
+ const outDir = resolve(root, '.stator-island-out') // virtual — write: false
40
+ const inMachinesDir = (abs: string): boolean =>
41
+ abs === machinesDir || abs.startsWith(machinesDir + sep)
42
+
43
+ const entryPoints = opts.entries.map((entry) => ({
44
+ in: resolve(entry.file),
45
+ out: entry.rel.replace(/\.stator$/, '').replace(/[\\/]/g, '_'),
46
+ }))
47
+ const byFile = new Map(opts.entries.map((e) => [norm(resolve(e.file)), e]))
48
+
49
+ // Rollup/Vite rewrite `new URL('./x', import.meta.url)` to an emitted asset;
50
+ // esbuild does not, so the seam does it here: each URL-referenced relative
51
+ // asset becomes an import (the file loader emits it hashed and exports its
52
+ // public URL, root-absolute under `publicPath`, so the `new URL` still
53
+ // resolves). Only string-literal relative specifiers — same limit as Vite.
54
+ const URL_ASSET_RE = /new\s+URL\(\s*(['"])(\.{1,2}\/[^'"\n]+)\1\s*,\s*import\.meta\.url\s*\)/g
55
+ const rewriteUrlAssets = (code: string): string => {
56
+ let n = 0
57
+ const imports: string[] = []
58
+ const out = code.replace(URL_ASSET_RE, (_m, _q: string, spec: string) => {
59
+ const id = `__stator_url_asset_${n++}`
60
+ imports.push(`import ${id} from ${JSON.stringify(spec)}`)
61
+ return `new URL(${id}, import.meta.url)`
62
+ })
63
+ return imports.length === 0 ? code : `${imports.join('\n')}\n${out}`
64
+ }
65
+
66
+ const statorPlugin = {
67
+ name: 'stator-client',
68
+ setup(b: import('esbuild').PluginBuild) {
69
+ b.onLoad({ filter: /\.stator$/ }, async (args) => {
70
+ const source = await readFile(args.path, 'utf8')
71
+ const kind = /[\\/]routes[\\/].*\.stator$/.test(args.path) ? 'route' : 'component'
72
+ const result = compile(source, {
73
+ id: args.path,
74
+ kind,
75
+ resolveRegions: regionResolverFor(args.path, source),
76
+ })
77
+ return {
78
+ contents: result.isClient ? rewriteUrlAssets(result.clientCode) : 'export {}',
79
+ loader: 'ts',
80
+ resolveDir: dirname(args.path),
81
+ }
82
+ })
83
+ b.onLoad({ filter: /\.(ts|mts|js|mjs)$/ }, async (args) => {
84
+ const source = await readFile(args.path, 'utf8')
85
+ const rewritten = rewriteUrlAssets(source)
86
+ if (rewritten === source) return undefined
87
+ return {
88
+ contents: rewritten,
89
+ loader: args.path.endsWith('js') ? 'js' : 'ts',
90
+ resolveDir: dirname(args.path),
91
+ }
92
+ })
93
+ },
94
+ }
95
+
96
+ const machineStubPlugin = {
97
+ name: 'stator-machine-stub',
98
+ setup(b: import('esbuild').PluginBuild) {
99
+ b.onResolve({ filter: /^[./]/ }, (args) => {
100
+ if (args.namespace === STUB_NS) return null
101
+ const base = isAbsolute(args.path) ? args.path : resolve(args.resolveDir, args.path)
102
+ const abs = [base, `${base}.ts`, `${base}.js`].find(inMachinesDir)
103
+ if (!abs) return null
104
+ return { path: abs, namespace: STUB_NS }
105
+ })
106
+ b.onLoad({ filter: /.*/, namespace: STUB_NS }, async (args) => {
107
+ let name: unknown
108
+ try {
109
+ const mod = (await import(pathToFileURL(args.path).href)) as {
110
+ default?: { name?: unknown }
111
+ }
112
+ name = mod.default?.name
113
+ } catch (err) {
114
+ throw new Error(
115
+ `stator: cannot stub server-machine import "${args.path}" for the client bundle — ` +
116
+ `importing it in Node failed: ${(err as Error).message}`,
117
+ )
118
+ }
119
+ if (typeof name !== 'string') {
120
+ throw new Error(
121
+ `stator: cannot stub "${args.path}" — it does not default-export a machine with a name. ` +
122
+ `Only machine defs may be imported from the machines directory in client code.`,
123
+ )
124
+ }
125
+ return { contents: `export default { name: ${JSON.stringify(name)} }\n`, loader: 'js' }
126
+ })
127
+ },
128
+ }
129
+
130
+ const result = await build({
131
+ absWorkingDir: root,
132
+ entryPoints,
133
+ outdir: outDir,
134
+ bundle: true,
135
+ splitting: true,
136
+ format: 'esm',
137
+ write: false,
138
+ metafile: true,
139
+ minify: true,
140
+ publicPath,
141
+ sourcemap: opts.sourcemap ? 'inline' : false,
142
+ entryNames: '[name]-[hash]',
143
+ chunkNames: 'chunks/[name]-[hash]',
144
+ assetNames: '[name]-[hash]',
145
+ loader: FILE_LOADERS,
146
+ logLevel: 'warning',
147
+ plugins: [machineStubPlugin, statorPlugin],
148
+ })
149
+
150
+ const assets: IslandAsset[] = result.outputFiles.map((f) => {
151
+ const fileName = norm(relative(outDir, f.path))
152
+ return fileName.endsWith('.js')
153
+ ? { fileName, source: f.text }
154
+ : { fileName, source: f.contents }
155
+ })
156
+
157
+ const modules = new Set<string>()
158
+ for (const key of Object.keys(result.metafile.inputs)) {
159
+ if (key.startsWith(`${STUB_NS}:`)) modules.add(norm(key.slice(STUB_NS.length + 1)))
160
+ else modules.add(norm(resolve(root, key)))
161
+ }
162
+
163
+ const islandUrls: Record<string, string> = {}
164
+ for (const [outPath, meta] of Object.entries(result.metafile.outputs)) {
165
+ if (!meta.entryPoint) continue
166
+ const entry = byFile.get(norm(resolve(root, meta.entryPoint)))
167
+ if (entry) islandUrls[entry.rel] = publicPath + norm(relative(outDir, resolve(root, outPath)))
168
+ }
169
+ for (const entry of opts.entries) {
170
+ if (!islandUrls[entry.rel])
171
+ throw new Error(`stator: island "${entry.rel}" missing from the bundle output`)
172
+ }
173
+
174
+ return { islandUrls, assets, modules: [...modules] }
175
+ }
@@ -0,0 +1,88 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Rollup } from 'vite'
3
+ import type { BundleIslandsOptions, IslandAsset, IslandBundler } from './islands.ts'
4
+
5
+ const norm = (p: string): string => p.replace(/\\/g, '/')
6
+ const stripQuery = (id: string): string => {
7
+ const q = id.indexOf('?')
8
+ return q === -1 ? id : id.slice(0, q)
9
+ }
10
+
11
+ /** Implementation #1: one Vite build over every island entry. Vite is imported
12
+ * lazily — server-only apps never load it. */
13
+ export const bundleIslandsVite: IslandBundler = async (opts: BundleIslandsOptions) => {
14
+ const [{ build: viteBuild }, { CLIENT_QUERY, MACHINE_STUB_PREFIX, machineStub, stator }] =
15
+ await Promise.all([import('vite'), import('../vite/index.ts')])
16
+ const publicPath = opts.publicPath ?? '/static/assets/'
17
+
18
+ const input: Record<string, string> = {}
19
+ const byFile = new Map<string, BundleIslandsOptions['entries'][number]>()
20
+ for (const entry of opts.entries) {
21
+ const file = resolve(entry.file)
22
+ input[entry.rel.replace(/\.stator$/, '').replace(/[\\/]/g, '_')] = file.endsWith('.stator')
23
+ ? `${file}?${CLIENT_QUERY}`
24
+ : file
25
+ byFile.set(norm(file), entry)
26
+ }
27
+
28
+ const result = await viteBuild({
29
+ root: opts.root,
30
+ // Assets referenced by URL from island code (`new URL(...)`, CSS `url()`)
31
+ // render against `base`, so it must be where the assets are served from.
32
+ base: publicPath,
33
+ logLevel: 'warn',
34
+ configFile: false,
35
+ plugins: [stator(), machineStub({ machinesDir: resolve(opts.machinesDir) })],
36
+ build: {
37
+ write: false,
38
+ // Seam contract: a URL-referenced asset (`new URL('./x.wasm',
39
+ // import.meta.url)`) is always emitted as a hashed FILE, never inlined
40
+ // as a data: URL — deterministic for callers and identical to what the
41
+ // esbuild implementation (file loader) produces.
42
+ assetsInlineLimit: 0,
43
+ sourcemap: opts.sourcemap ? 'inline' : false,
44
+ rollupOptions: {
45
+ input,
46
+ output: {
47
+ entryFileNames: '[name]-[hash].js',
48
+ chunkFileNames: 'chunks/[name]-[hash].js',
49
+ assetFileNames: '[name]-[hash][extname]',
50
+ },
51
+ },
52
+ },
53
+ })
54
+ const outputs = (Array.isArray(result) ? result : [result]).filter(
55
+ (r): r is Rollup.RollupOutput => 'output' in r,
56
+ )
57
+
58
+ const islandUrls: Record<string, string> = {}
59
+ const assets: IslandAsset[] = []
60
+ const modules = new Set<string>()
61
+ const sourcePathOf = (id: string): string | null => {
62
+ const raw = id.startsWith(MACHINE_STUB_PREFIX) ? id.slice(MACHINE_STUB_PREFIX.length) : id
63
+ if (raw.startsWith('\0')) return null
64
+ return norm(stripQuery(raw))
65
+ }
66
+ for (const out of outputs) {
67
+ for (const item of out.output) {
68
+ if (item.type === 'chunk') {
69
+ assets.push({ fileName: item.fileName, source: item.code })
70
+ for (const id of Object.keys(item.modules)) {
71
+ const p = sourcePathOf(id)
72
+ if (p) modules.add(p)
73
+ }
74
+ if (item.isEntry && item.facadeModuleId) {
75
+ const entry = byFile.get(norm(stripQuery(item.facadeModuleId)))
76
+ if (entry) islandUrls[entry.rel] = publicPath + item.fileName
77
+ }
78
+ } else {
79
+ assets.push({ fileName: item.fileName, source: item.source })
80
+ }
81
+ }
82
+ }
83
+ for (const entry of opts.entries) {
84
+ if (!islandUrls[entry.rel])
85
+ throw new Error(`stator: island "${entry.rel}" missing from the bundle output`)
86
+ }
87
+ return { islandUrls, assets, modules: [...modules] }
88
+ }
@@ -1,6 +1,5 @@
1
1
  import { readdir, readFile } from 'node:fs/promises'
2
2
  import { join, resolve } from 'node:path'
3
- import type { Rollup } from 'vite'
4
3
 
5
4
  /**
6
5
  * The island-bundling seam — Lock 1 of the Vite exit (spec
@@ -59,89 +58,23 @@ export interface BundleIslandsOptions {
59
58
 
60
59
  export type IslandBundler = (opts: BundleIslandsOptions) => Promise<IslandBundle>
61
60
 
62
- const norm = (p: string): string => p.replace(/\\/g, '/')
63
- const stripQuery = (id: string): string => {
64
- const q = id.indexOf('?')
65
- return q === -1 ? id : id.slice(0, q)
66
- }
67
-
68
- /** Implementation #1: one Vite build over every island entry. Vite is imported
69
- * lazily — server-only apps never load it. */
61
+ /**
62
+ * The seam's one export: dispatches to an implementation. Vite (impl #1,
63
+ * `islands-vite.ts`) is the default; `STATOR_ISLAND_BUNDLER=esbuild` selects
64
+ * impl #2 (`islands-esbuild.ts`, the Option E candidate — Spike 1). Both run
65
+ * the same contract tests; the default flips (and #1 is deleted) when the
66
+ * Phase 2 gate in the toolchain-adapter spec passes.
67
+ */
70
68
  export const bundleIslands: IslandBundler = async (opts) => {
71
- const [{ build: viteBuild }, { CLIENT_QUERY, MACHINE_STUB_PREFIX, machineStub, stator }] =
72
- await Promise.all([import('vite'), import('../vite/index.ts')])
73
- const publicPath = opts.publicPath ?? '/static/assets/'
74
-
75
- const input: Record<string, string> = {}
76
- const byFile = new Map<string, IslandEntry>()
77
- for (const entry of opts.entries) {
78
- const file = resolve(entry.file)
79
- input[entry.rel.replace(/\.stator$/, '').replace(/[\\/]/g, '_')] = file.endsWith('.stator')
80
- ? `${file}?${CLIENT_QUERY}`
81
- : file
82
- byFile.set(norm(file), entry)
83
- }
84
-
85
- const result = await viteBuild({
86
- root: opts.root,
87
- // Assets referenced by URL from island code (`new URL(...)`, CSS `url()`)
88
- // render against `base`, so it must be where the assets are served from.
89
- base: publicPath,
90
- logLevel: 'warn',
91
- configFile: false,
92
- plugins: [stator(), machineStub({ machinesDir: resolve(opts.machinesDir) })],
93
- build: {
94
- write: false,
95
- // Seam contract: a URL-referenced asset (`new URL('./x.wasm',
96
- // import.meta.url)`) is always emitted as a hashed FILE, never inlined
97
- // as a data: URL — deterministic for callers and identical to what an
98
- // esbuild implementation (file loader) produces.
99
- assetsInlineLimit: 0,
100
- sourcemap: opts.sourcemap ? 'inline' : false,
101
- rollupOptions: {
102
- input,
103
- output: {
104
- entryFileNames: '[name]-[hash].js',
105
- chunkFileNames: 'chunks/[name]-[hash].js',
106
- assetFileNames: '[name]-[hash][extname]',
107
- },
108
- },
109
- },
110
- })
111
- const outputs = (Array.isArray(result) ? result : [result]).filter(
112
- (r): r is Rollup.RollupOutput => 'output' in r,
113
- )
114
-
115
- const islandUrls: Record<string, string> = {}
116
- const assets: IslandAsset[] = []
117
- const modules = new Set<string>()
118
- const sourcePathOf = (id: string): string | null => {
119
- const raw = id.startsWith(MACHINE_STUB_PREFIX) ? id.slice(MACHINE_STUB_PREFIX.length) : id
120
- if (raw.startsWith('\0')) return null
121
- return norm(stripQuery(raw))
122
- }
123
- for (const out of outputs) {
124
- for (const item of out.output) {
125
- if (item.type === 'chunk') {
126
- assets.push({ fileName: item.fileName, source: item.code })
127
- for (const id of Object.keys(item.modules)) {
128
- const p = sourcePathOf(id)
129
- if (p) modules.add(p)
130
- }
131
- if (item.isEntry && item.facadeModuleId) {
132
- const entry = byFile.get(norm(stripQuery(item.facadeModuleId)))
133
- if (entry) islandUrls[entry.rel] = publicPath + item.fileName
134
- }
135
- } else {
136
- assets.push({ fileName: item.fileName, source: item.source })
137
- }
138
- }
139
- }
140
- for (const entry of opts.entries) {
141
- if (!islandUrls[entry.rel])
142
- throw new Error(`stator: island "${entry.rel}" missing from the bundle output`)
69
+ const choice = process.env.STATOR_ISLAND_BUNDLER ?? 'vite'
70
+ if (choice === 'esbuild') {
71
+ const { bundleIslandsEsbuild } = await import('./islands-esbuild.ts')
72
+ return bundleIslandsEsbuild(opts)
143
73
  }
144
- return { islandUrls, assets, modules: [...modules] }
74
+ if (choice !== 'vite')
75
+ throw new Error(`stator: unknown STATOR_ISLAND_BUNDLER "${choice}" (vite | esbuild)`)
76
+ const { bundleIslandsVite } = await import('./islands-vite.ts')
77
+ return bundleIslandsVite(opts)
145
78
  }
146
79
 
147
80
  /**
@@ -1,4 +1,4 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { readFile, stat } from 'node:fs/promises'
2
2
  import { dirname, extname, resolve, sep } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { build } from 'esbuild'
@@ -282,6 +282,38 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
282
282
  return c.text('forbidden', 403)
283
283
  }
284
284
  try {
285
+ const st = await stat(full)
286
+ if (!st.isFile()) return c.text('not found', 404)
287
+
288
+ // Caching contract: `/static/assets/*` is the framework's hashed-output
289
+ // namespace (island bundles, emitted URL assets) — content-addressed by
290
+ // construction, so a year of `immutable` is correct. Everything else
291
+ // under `/static/` is user files with stable names: always revalidate,
292
+ // but revalidation is a bodyless 304 (ETag from size+mtime, plus
293
+ // Last-Modified for validators that only speak dates).
294
+ const etag = `"${st.size.toString(16)}-${Math.trunc(st.mtimeMs).toString(16)}"`
295
+ c.header(
296
+ 'Cache-Control',
297
+ rel.startsWith('assets/')
298
+ ? 'public, max-age=31536000, immutable'
299
+ : 'public, max-age=0, must-revalidate',
300
+ )
301
+ c.header('ETag', etag)
302
+ c.header('Last-Modified', st.mtime.toUTCString())
303
+
304
+ const inm = c.req.header('if-none-match')
305
+ if (inm) {
306
+ if (inm.split(',').some((t) => t.trim().replace(/^W\//, '') === etag)) {
307
+ return c.body(null, 304)
308
+ }
309
+ } else {
310
+ const ims = Date.parse(c.req.header('if-modified-since') ?? '')
311
+ // HTTP dates have second precision; compare mtime truncated to match.
312
+ if (!Number.isNaN(ims) && Math.trunc(st.mtimeMs / 1000) * 1000 <= ims) {
313
+ return c.body(null, 304)
314
+ }
315
+ }
316
+
285
317
  const buf = await readFile(full)
286
318
  c.header('Content-Type', contentTypeFor(rel))
287
319
  return c.body(buf)
@@ -684,10 +716,13 @@ export function contentTypeFor(path: string): string {
684
716
  if (ext === '.png') return 'image/png'
685
717
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
686
718
  if (ext === '.webp') return 'image/webp'
719
+ if (ext === '.avif') return 'image/avif'
687
720
  if (ext === '.gif') return 'image/gif'
688
721
  if (ext === '.ico') return 'image/x-icon'
689
722
  if (ext === '.wasm') return 'application/wasm'
690
723
  if (ext === '.woff2') return 'font/woff2'
691
724
  if (ext === '.woff') return 'font/woff'
725
+ if (ext === '.ttf') return 'font/ttf'
726
+ if (ext === '.otf') return 'font/otf'
692
727
  return 'application/octet-stream'
693
728
  }
@@ -41,10 +41,12 @@ export interface DiscoveredRoute {
41
41
  * Walk the routes directory recursively and build URL patterns from file
42
42
  * paths. Conventions:
43
43
  *
44
- * - `routes/foo.ts` → `/foo`
45
- * - `routes/foo/index.ts` → `/foo`
46
- * - `routes/foo/[id].ts` → `/foo/:id` (path param `id`)
47
- * - `routes/[a]/[b].ts` → `/:a/:b`
44
+ * - `routes/foo.ts` → `/foo`
45
+ * - `routes/foo/index.ts` → `/foo`
46
+ * - `routes/foo/[id].ts` → `/foo/:id` (path param `id`)
47
+ * - `routes/[a]/[b].ts` → `/:a/:b`
48
+ * - `routes/foo/[...rest].ts` → `/foo/*rest` (catch-all: terminal, matches
49
+ * zero+ segments, param is the raw remainder incl. slashes)
48
50
  *
49
51
  * Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
50
52
  * GET is a `defineRoute` (page renderer) or a `defineApiRoute` declaring