@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 +2 -2
- package/src/build/islands-esbuild.ts +175 -0
- package/src/build/islands-vite.ts +88 -0
- package/src/build/islands.ts +15 -82
- package/src/server/http.ts +36 -1
- package/src/server/route-discovery.ts +6 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
+
}
|
package/src/build/islands.ts
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/server/http.ts
CHANGED
|
@@ -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`
|
|
45
|
-
* - `routes/foo/index.ts`
|
|
46
|
-
* - `routes/foo/[id].ts`
|
|
47
|
-
* - `routes/[a]/[b].ts`
|
|
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
|