acrylblends 0.7.0 → 0.7.2

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 (50) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +75 -4
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +2 -1
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/api/summary.md +7 -4
  7. package/docs/framework/cordis/node-server.md +3 -0
  8. package/docs/framework/getting-started/deploy.md +133 -0
  9. package/docs/framework/getting-started/desktop.md +1 -1
  10. package/docs/framework/getting-started/nodejs.md +37 -16
  11. package/docs/framework/guides/client-modules.md +175 -0
  12. package/docs/framework/guides/forms-and-sessions.md +231 -0
  13. package/docs/framework/guides/jsx-dom.md +12 -7
  14. package/docs/framework/guides/validation.md +88 -1
  15. package/docs/framework/helpers/cookie.md +1 -1
  16. package/docs/framework/helpers/testing.md +17 -0
  17. package/docs/framework/upstream.json +3 -3
  18. package/lib/browser/client.js +307 -0
  19. package/lib/browser/jsx-dom.js +1432 -0
  20. package/lib/index.js +1 -25
  21. package/lib/node-server/register.js +7 -49
  22. package/lib/node-server.d.ts +74 -7
  23. package/lib/node-server.js +264 -7
  24. package/lib/schema-validator.d.ts +40 -14
  25. package/lib/schema-validator.js +2 -2
  26. package/lib/src-0hI7M7cN.js +27 -0
  27. package/lib/transpile-CTnWof4z.js +90 -0
  28. package/package.json +1 -1
  29. package/plugins/acryl-inspect/upstream.json +1 -1
  30. package/plugins/acryl-node-server/src/adapter/server.ts +28 -5
  31. package/plugins/acryl-node-server/src/adapter/types.ts +14 -2
  32. package/plugins/acryl-node-server/src/client-modules.ts +293 -0
  33. package/plugins/acryl-node-server/src/register.ts +5 -62
  34. package/plugins/acryl-node-server/src/transpile.ts +120 -0
  35. package/plugins/acryl-node-server/upstream.json +4 -3
  36. package/plugins/acryl-router/src/browser/client.ts +3 -0
  37. package/plugins/acryl-router/src/browser/jsx-dom.ts +9 -0
  38. package/plugins/acryl-router/src/helpers/index.ts +1 -1
  39. package/plugins/acryl-router/src/helpers/schema-validator.ts +39 -12
  40. package/plugins/acryl-router/upstream.json +2 -2
  41. package/plugins/acryl-router-http/upstream.json +1 -1
  42. package/plugins/acryl-state/upstream.json +1 -1
  43. package/plugins/desktop/plugin.json +1 -1
  44. package/plugins/desktop/upstream.json +1 -1
  45. package/plugins/desktop-web/plugin.json +1 -1
  46. package/plugins/desktop-web/upstream.json +1 -1
  47. package/templates/agent/package.json +4 -3
  48. package/templates/blank/package.json +4 -3
  49. package/templates/todo/package.json +4 -3
  50. package/templates/todo/upstream.json +1 -1
@@ -0,0 +1,90 @@
1
+ import { createRequire } from "node:module";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ //#region ../../plugins/acryl-node-server/src/transpile.ts
5
+ /**
6
+ * One TypeScript module compiled to JavaScript with the app's own `typescript`: the transform behind `acrylblends/node-server/register`
7
+ * (server JSX on Node) and `clientModules` (browser modules). `typescript` is loaded from the app on first use, so the framework itself has
8
+ * no dependencies.
9
+ */
10
+ /** Thrown when the app has no `typescript` to compile a file with. */
11
+ var MissingTypeScriptError = class extends Error {
12
+ constructor(file, what) {
13
+ super(`acrylblends: ${file} ${what}, and compiling it needs the typescript package in this app: npm install --save-dev typescript`);
14
+ this.name = "MissingTypeScriptError";
15
+ }
16
+ };
17
+ const compilers = /* @__PURE__ */ new Map();
18
+ const importSources = /* @__PURE__ */ new Map();
19
+ /**
20
+ * The app's own `typescript`, resolved from the file, so each app compiles with the version it pinned.
21
+ * @param file - the module to compile.
22
+ * @param what - what the file needs compiling for, for the error message.
23
+ * @returns the compiler.
24
+ * @throws MissingTypeScriptError when the app has no `typescript`.
25
+ */
26
+ function compilerFor(file, what) {
27
+ let owner;
28
+ for (let dir = dirname(file); owner === void 0; dir = dirname(dir)) if (existsSync(join(dir, "node_modules", "typescript", "package.json"))) owner = dir;
29
+ else if (dirname(dir) === dir) throw new MissingTypeScriptError(file, what);
30
+ const path = createRequire(join(owner, "package.json")).resolve("typescript");
31
+ let compiler = compilers.get(path);
32
+ if (compiler === void 0) {
33
+ compiler = createRequire(path)(path);
34
+ compilers.set(path, compiler);
35
+ }
36
+ return compiler;
37
+ }
38
+ /** `jsxImportSource` of the tsconfig.json nearest to the file (following `extends`), read once per config. */
39
+ function importSourceFor(file, ts, fallback) {
40
+ const config = ts.findConfigFile(dirname(file), (path) => existsSync(path));
41
+ if (config === void 0) return fallback;
42
+ let source = importSources.get(`${config}\0${fallback}`);
43
+ if (source === void 0) {
44
+ const { config: json } = ts.readConfigFile(config, (path) => ts.sys.readFile(path));
45
+ source = ts.parseJsonConfigFileContent(json ?? {}, ts.sys, dirname(config)).options.jsxImportSource ?? fallback;
46
+ importSources.set(`${config}\0${fallback}`, source);
47
+ }
48
+ return source;
49
+ }
50
+ /**
51
+ * A TypeScript or JSX module compiled to an ES module, with its source map inline (a file's own `@jsxImportSource` pragma wins over the
52
+ * options).
53
+ * @param file - absolute path of the module, which also locates the app's `typescript` and tsconfig.json.
54
+ * @param text - its source.
55
+ * @param options - the JSX runtime and the specifier rewrite.
56
+ * @returns the JavaScript.
57
+ * @throws MissingTypeScriptError when the app has no `typescript`.
58
+ */
59
+ function compileModule(file, text, options) {
60
+ const ts = compilerFor(file, options.what);
61
+ const output = ts.transpileModule(text, {
62
+ fileName: file,
63
+ compilerOptions: {
64
+ module: "esnext",
65
+ target: "es2022",
66
+ jsx: "react-jsx",
67
+ jsxImportSource: options.importSource ?? importSourceFor(file, ts, options.defaultImportSource),
68
+ allowImportingTsExtensions: true,
69
+ inlineSourceMap: true,
70
+ inlineSources: true
71
+ }
72
+ }).outputText;
73
+ return options.rewrite === void 0 ? output : rewriteSpecifiers(ts, output, options.rewrite);
74
+ }
75
+ /** The output with each import specifier `rewrite` maps replaced; TypeScript's lexer finds them, so strings and comments are left alone. */
76
+ function rewriteSpecifiers(ts, code, rewrite) {
77
+ let result = "";
78
+ let last = 0;
79
+ for (const { fileName, pos } of ts.preProcessFile(code, true, true).importedFiles.toSorted((a, b) => a.pos - b.pos)) {
80
+ const start = pos + 1;
81
+ if (code.slice(start, start + fileName.length) !== fileName) continue;
82
+ const replacement = rewrite(fileName);
83
+ if (replacement === void 0) continue;
84
+ result += code.slice(last, start) + replacement;
85
+ last = start + fileName.length;
86
+ }
87
+ return result + code.slice(last);
88
+ }
89
+ //#endregion
90
+ export { compileModule as n, MissingTypeScriptError as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-inspect",
5
- "commit": "7bba3ed0420d87e61d8256fa60614d22af734c8f"
5
+ "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -1,10 +1,28 @@
1
1
  import { createServer as createServerHTTP } from 'node:http'
2
+ import type { Server } from 'node:http'
3
+ import type { Http2SecureServer, Http2Server } from 'node:http2'
4
+ import type { Server as HttpsServer } from 'node:https'
2
5
  import type { AddressInfo } from 'node:net'
3
6
  import { getRequestListener } from './listener.ts'
4
- import type { Options, ServerType } from './types.ts'
7
+ import type {
8
+ HttpOptions,
9
+ HttpsOptions,
10
+ Http2Options,
11
+ Options,
12
+ SecureHttp2Options,
13
+ ServerType,
14
+ } from './types.ts'
5
15
  import { setupWebSocket } from './websocket.ts'
6
16
 
7
- export const createAdaptorServer = (options: Options): ServerType => {
17
+ /** The interface `serve` listens on when `hostname` is not given: this machine only. Pass `hostname: '0.0.0.0'` to accept other machines. */
18
+ export const DEFAULT_HOSTNAME = '127.0.0.1'
19
+
20
+ export function createAdaptorServer(options: SecureHttp2Options): Http2SecureServer
21
+ export function createAdaptorServer(options: Http2Options): Http2Server
22
+ export function createAdaptorServer(options: HttpsOptions): HttpsServer
23
+ export function createAdaptorServer(options: HttpOptions): Server
24
+ export function createAdaptorServer(options: Options): ServerType
25
+ export function createAdaptorServer(options: Options): ServerType {
8
26
  const fetchCallback = options.fetch
9
27
  const requestListener = getRequestListener(fetchCallback, {
10
28
  hostname: options.hostname,
@@ -23,12 +41,17 @@ export const createAdaptorServer = (options: Options): ServerType => {
23
41
  return server
24
42
  }
25
43
 
26
- export const serve = (
44
+ export function serve(options: SecureHttp2Options, listeningListener?: (info: AddressInfo) => void): Http2SecureServer
45
+ export function serve(options: Http2Options, listeningListener?: (info: AddressInfo) => void): Http2Server
46
+ export function serve(options: HttpsOptions, listeningListener?: (info: AddressInfo) => void): HttpsServer
47
+ export function serve(options: HttpOptions, listeningListener?: (info: AddressInfo) => void): Server
48
+ export function serve(options: Options, listeningListener?: (info: AddressInfo) => void): ServerType
49
+ export function serve(
27
50
  options: Options,
28
51
  listeningListener?: (info: AddressInfo) => void
29
- ): ServerType => {
52
+ ): ServerType {
30
53
  const server = createAdaptorServer(options)
31
- server.listen(options?.port ?? 3000, options.hostname, () => {
54
+ server.listen(options?.port ?? 3000, options.hostname ?? DEFAULT_HOSTNAME, () => {
32
55
  const serverInfo = server.address() as AddressInfo
33
56
  listeningListener && listeningListener(serverInfo)
34
57
  })
@@ -68,15 +68,27 @@ export type ServerOptions =
68
68
  | createHttp2Options
69
69
  | createSecureHttp2Options
70
70
 
71
- export type Options = {
71
+ type BaseOptions = {
72
72
  fetch: FetchCallback
73
73
  overrideGlobalObjects?: boolean
74
74
  autoCleanupIncoming?: boolean
75
75
  port?: number
76
+ /** The interface to listen on; `serve` uses `127.0.0.1` when it is not given. */
76
77
  hostname?: string
77
78
  websocket?: {
78
79
  server: WebSocketServerLike
79
80
  }
80
- } & ServerOptions
81
+ }
82
+
83
+ export type Options = BaseOptions & ServerOptions
84
+
85
+ /** Options for the default HTTP/1 server: `serve` and `createAdaptorServer` return an `http.Server`. */
86
+ export type HttpOptions = BaseOptions & createHttpOptions
87
+ /** Options naming `https.createServer`: an `https.Server`. */
88
+ export type HttpsOptions = BaseOptions & { serverOptions?: HttpsServerOptions, createServer: typeof createHttpsServer }
89
+ /** Options naming `http2.createServer`: an `Http2Server`. */
90
+ export type Http2Options = BaseOptions & { serverOptions?: Http2ServerOptions, createServer: typeof createHttp2Server }
91
+ /** Options naming `http2.createSecureServer`: an `Http2SecureServer`. */
92
+ export type SecureHttp2Options = BaseOptions & { serverOptions?: SecureHttp2ServerOptions, createServer: typeof createSecureHttp2Server }
81
93
 
82
94
  export type CustomErrorHandler = (err: unknown) => void | Response | Promise<void | Response>
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Client components without a bundler. `clientModules({ root: 'src/client' })` is an app that serves the TypeScript and JSX modules of a
3
+ * folder to the browser as ES modules: a `.tsx`/`.ts`/`.jsx` file compiled with the app's own `typescript` (JSX for `acrylblends/jsx/dom`),
4
+ * a `.js`/`.mjs` file as it is. The framework's own browser imports (`acrylblends/jsx/dom` with its runtimes and `client`, and
5
+ * `acrylblends/client`) are rewritten to two self-contained files the same app serves, at a URL relative to each module, so a page needs
6
+ * one `<script type="module" src="/client/counter.tsx">` and no import map, under whatever path the app is routed at.
7
+ *
8
+ * Production runs without `typescript` (a deploy that leaves out devDependencies): `buildClientModules` writes the same compiled modules
9
+ * and the two browser files into a folder, and with `NODE_ENV=production` and `compiled` set, only that folder is served.
10
+ */
11
+
12
+ import { copyFileSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
13
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { Acryl } from '../../acryl-router/src/index.ts'
16
+ import { basePath } from '../../acryl-router/src/helpers/index.ts'
17
+ import { MissingTypeScriptError, compileModule } from './transpile.ts'
18
+
19
+ /** The folder the two browser files are served from, below the app's mount. A source folder of this name is not served. */
20
+ const RUNTIME_DIR = '@acrylblends'
21
+ /** The framework's browser imports and the browser file that provides each. */
22
+ const BROWSER_IMPORTS: Readonly<Record<string, string>> = {
23
+ 'acrylblends/jsx/dom': 'jsx-dom.js',
24
+ 'acrylblends/jsx/dom/jsx-runtime': 'jsx-dom.js',
25
+ 'acrylblends/jsx/dom/jsx-dev-runtime': 'jsx-dom.js',
26
+ 'acrylblends/jsx/dom/client': 'jsx-dom.js',
27
+ 'acrylblends/client': 'client.js',
28
+ }
29
+ const RUNTIME_FILES = ['jsx-dom.js', 'client.js'] as const
30
+ const COMPILED = new Set(['.ts', '.tsx', '.jsx', '.mts'])
31
+ const SERVED = new Set([...COMPILED, '.js', '.mjs'])
32
+ const JS_TYPE = 'text/javascript; charset=utf-8'
33
+ /** The cache bound when `cacheBytes` is not given. */
34
+ const DEFAULT_CACHE_BYTES = 16 * 1024 * 1024
35
+
36
+ /** Options of {@link clientModules}. */
37
+ export interface ClientModulesOptions {
38
+ /** The folder of client modules, absolute or relative to the working directory. */
39
+ readonly root: string
40
+ /** The folder {@link buildClientModules} wrote; with `NODE_ENV=production` it is served instead of compiling, which needs no `typescript`. */
41
+ readonly compiled?: string | undefined
42
+ /** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
43
+ readonly cacheBytes?: number | undefined
44
+ /** The folder holding `jsx-dom.js` and `client.js`; the framework's own when not given. */
45
+ readonly runtimeDir?: string | undefined
46
+ }
47
+
48
+ /** Options of {@link buildClientModules}. */
49
+ export interface BuildClientModulesOptions {
50
+ /** The folder of client modules, absolute or relative to the working directory. */
51
+ readonly root: string
52
+ /** Where the compiled modules go, at the same relative paths and file names; it must not lie inside `root`. */
53
+ readonly outDir: string
54
+ /** The folder holding `jsx-dom.js` and `client.js`; the framework's own when not given. */
55
+ readonly runtimeDir?: string | undefined
56
+ }
57
+
58
+ /** The framework's two browser files, next to the built module that contains this code. */
59
+ const defaultRuntimeDir = (): string => fileURLToPath(new URL('./browser/', import.meta.url))
60
+
61
+ /** A module path below the root (`widgets/counter.tsx`) with the URL prefix that reaches the browser files from it. */
62
+ const runtimePrefix = (relativePath: string): string => {
63
+ const depth = relativePath.split('/').length - 1
64
+ return depth === 0 ? './' : '../'.repeat(depth)
65
+ }
66
+
67
+ /** One client module compiled for the browser: TypeScript and JSX compiled, the framework's imports pointed at its browser files. */
68
+ function compileForBrowser(file: string, relativePath: string): string {
69
+ const prefix = runtimePrefix(relativePath)
70
+ return compileModule(file, readFileSync(file, 'utf8'), {
71
+ // A browser module renders with the DOM runtime whatever the server's tsconfig.json says; a file's own pragma still wins.
72
+ importSource: 'acrylblends/jsx/dom',
73
+ defaultImportSource: 'acrylblends/jsx/dom',
74
+ what: 'is a client module',
75
+ rewrite: (specifier) => {
76
+ const target = BROWSER_IMPORTS[specifier]
77
+ return target === undefined ? undefined : `${prefix}${RUNTIME_DIR}/${target}`
78
+ },
79
+ })
80
+ }
81
+
82
+ /**
83
+ * The path below the root a request names, or `undefined` when it must not be served: a `..` or dot segment, a backslash or NUL, an
84
+ * extension outside the served set, or a `.d.ts` file.
85
+ */
86
+ function servedPath(requestPath: string): string | undefined {
87
+ let decoded: string
88
+ try {
89
+ decoded = decodeURIComponent(requestPath)
90
+ } catch {
91
+ return undefined
92
+ }
93
+ const parts = decoded.split('/').filter(part => part !== '')
94
+ if (parts.length === 0 || parts.some(part => part.startsWith('.') || part.includes('\\') || part.includes('\0'))) return undefined
95
+ const path = parts.join('/')
96
+ if (!SERVED.has(extname(path)) || path.endsWith('.d.ts')) return undefined
97
+ return path
98
+ }
99
+
100
+ /** A real file inside `root` (symbolic links followed), or `undefined`. */
101
+ function fileInside(root: string, path: string): string | undefined {
102
+ try {
103
+ const file = realpathSync(join(root, ...path.split('/')))
104
+ return file.startsWith(root + sep) && statSync(file).isFile() ? file : undefined
105
+ } catch {
106
+ return undefined
107
+ }
108
+ }
109
+
110
+ /** A file's validator: its size and modification time. */
111
+ const etagOf = (stat: { size: number, mtimeMs: number }): string => `"${stat.size.toString(36)}-${Math.trunc(stat.mtimeMs).toString(36)}"`
112
+
113
+ interface Cached {
114
+ readonly mtimeMs: number
115
+ readonly size: number
116
+ readonly etag: string
117
+ readonly code: string
118
+ readonly bytes: number
119
+ }
120
+
121
+ /** Compiled modules, least recently served first, bounded by the UTF-8 bytes of their code. Exported for its tests. */
122
+ export class CompileCache {
123
+ private readonly entries = new Map<string, Cached>()
124
+ private bytes = 0
125
+ private readonly limit: number
126
+ constructor(limit: number) {
127
+ this.limit = limit
128
+ }
129
+
130
+ get(file: string, mtimeMs: number, size: number): Cached | undefined {
131
+ const entry = this.entries.get(file)
132
+ if (entry === undefined) return undefined
133
+ this.entries.delete(file)
134
+ if (entry.mtimeMs !== mtimeMs || entry.size !== size) {
135
+ this.bytes -= entry.bytes
136
+ return undefined
137
+ }
138
+ this.entries.set(file, entry)
139
+ return entry
140
+ }
141
+
142
+ set(file: string, stat: { mtimeMs: number, size: number }, code: string): Cached {
143
+ const entry: Cached = { mtimeMs: stat.mtimeMs, size: stat.size, etag: etagOf(stat), code, bytes: Buffer.byteLength(code) }
144
+ const previous = this.entries.get(file)
145
+ if (previous !== undefined) {
146
+ this.entries.delete(file)
147
+ this.bytes -= previous.bytes
148
+ }
149
+ // A module larger than the whole bound is served but not kept.
150
+ if (entry.bytes > this.limit) return entry
151
+ this.entries.set(file, entry)
152
+ this.bytes += entry.bytes
153
+ for (const [key, old] of this.entries) {
154
+ if (this.bytes <= this.limit) break
155
+ this.entries.delete(key)
156
+ this.bytes -= old.bytes
157
+ }
158
+ return entry
159
+ }
160
+
161
+ /** The UTF-8 bytes of code held now. */
162
+ get heldBytes(): number {
163
+ return this.bytes
164
+ }
165
+
166
+ /** The files held now, least recently served first. */
167
+ get files(): string[] {
168
+ return [...this.entries.keys()]
169
+ }
170
+ }
171
+
172
+ const absolute = (path: string): string => (isAbsolute(path) ? path : resolve(path))
173
+
174
+ /** A folder's real path; one that does not exist (a production image that ships only the compiled folder) keeps its absolute path. */
175
+ const realFolder = (path: string): string => {
176
+ try {
177
+ return realpathSync(absolute(path))
178
+ } catch {
179
+ return absolute(path)
180
+ }
181
+ }
182
+
183
+ /**
184
+ * An app serving a folder of client modules to the browser. Route it where the page's script tags point: `app.route('/client',
185
+ * clientModules({ root: 'src/client' }))` serves `src/client/counter.tsx` at `/client/counter.tsx`. Files are compiled on request and
186
+ * cached until they change (bounded by `cacheBytes`); a response carries an ETag and `Cache-Control: no-cache`, so an edit shows on the
187
+ * next reload. With `NODE_ENV=production` and `compiled` set, only the `compiled` folder is served and nothing is compiled.
188
+ * @param options - the folder, the precompiled folder, and the cache bound.
189
+ * @returns the app.
190
+ */
191
+ export function clientModules(options: ClientModulesOptions): Acryl {
192
+ const root = realFolder(options.root)
193
+ const runtimeDir = absolute(options.runtimeDir ?? defaultRuntimeDir())
194
+ let compiled: string | undefined
195
+ if (process.env.NODE_ENV === 'production' && options.compiled !== undefined) {
196
+ try {
197
+ compiled = realpathSync(absolute(options.compiled))
198
+ } catch {
199
+ throw new Error(`acrylblends: the compiled client folder ${absolute(options.compiled)} does not exist; build it with buildClientModules before starting in production`)
200
+ }
201
+ }
202
+ const cache = new CompileCache(options.cacheBytes ?? DEFAULT_CACHE_BYTES)
203
+
204
+ const app = new Acryl()
205
+ app.get('/*', (c) => {
206
+ const requestPath = c.req.path.slice(basePath(c).length)
207
+ const runtime = /^\/@acrylblends\/([a-z-]+\.js)$/u.exec(requestPath)?.[1]
208
+ const send = (code: string, etag: string): Response => {
209
+ if (c.req.header('if-none-match') === etag) return c.body(null, 304, { etag, 'cache-control': 'no-cache' })
210
+ return c.body(code, 200, { 'content-type': JS_TYPE, etag, 'cache-control': 'no-cache' })
211
+ }
212
+ const serveFile = (file: string): Response => {
213
+ const stat = statSync(file)
214
+ return send(readFileSync(file, 'utf8'), etagOf(stat))
215
+ }
216
+
217
+ if (runtime !== undefined) {
218
+ if (!(RUNTIME_FILES as readonly string[]).includes(runtime)) return c.notFound()
219
+ return serveFile(compiled === undefined ? join(runtimeDir, runtime) : join(compiled, RUNTIME_DIR, runtime))
220
+ }
221
+ const path = servedPath(requestPath)
222
+ if (path === undefined || path.split('/')[0] === RUNTIME_DIR) return c.notFound()
223
+
224
+ if (compiled !== undefined) {
225
+ const file = fileInside(compiled, path)
226
+ if (file !== undefined) return serveFile(file)
227
+ if (fileInside(root, path) !== undefined) {
228
+ return c.text(`acrylblends: ${path} is not in ${relative(process.cwd(), compiled) || compiled}; build the client modules again (buildClientModules)`, 500)
229
+ }
230
+ return c.notFound()
231
+ }
232
+
233
+ const file = fileInside(root, path)
234
+ if (file === undefined) return c.notFound()
235
+ if (!COMPILED.has(extname(file))) return serveFile(file)
236
+ const stat = statSync(file)
237
+ let entry = cache.get(file, stat.mtimeMs, stat.size)
238
+ if (entry === undefined) {
239
+ let code: string
240
+ try {
241
+ code = compileForBrowser(file, path)
242
+ } catch (error) {
243
+ if (error instanceof MissingTypeScriptError) return c.text(error.message, 500)
244
+ throw error
245
+ }
246
+ entry = cache.set(file, stat, code)
247
+ }
248
+ return send(entry.code, entry.etag)
249
+ })
250
+ return app
251
+ }
252
+
253
+ /** Every served file below `dir`, as paths below `root` with `/` separators; dot entries, `node_modules` and the runtime folder skipped. */
254
+ function sourceFiles(root: string, dir = root): string[] {
255
+ const files: string[] = []
256
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
257
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || (dir === root && entry.name === RUNTIME_DIR)) continue
258
+ const full = join(dir, entry.name)
259
+ if (entry.isDirectory()) files.push(...sourceFiles(root, full))
260
+ else if (entry.isFile() && SERVED.has(extname(entry.name)) && !entry.name.endsWith('.d.ts')) files.push(relative(root, full).split(sep).join('/'))
261
+ }
262
+ return files.sort()
263
+ }
264
+
265
+ /**
266
+ * Compile a folder of client modules once, for a production that has no `typescript`: each module is written to `outDir` at the same
267
+ * relative path and file name (so the page's URLs stay the same), compiled exactly as {@link clientModules} compiles it, and the two
268
+ * browser files are copied to `outDir/@acrylblends/`. Files already in `outDir` that no source produces are left there.
269
+ * @param options - the source folder and the output folder.
270
+ * @returns the paths written, relative to `outDir`.
271
+ * @throws MissingTypeScriptError when the app has no `typescript`; an error when `outDir` lies inside `root`.
272
+ */
273
+ export function buildClientModules(options: BuildClientModulesOptions): string[] {
274
+ const root = realpathSync(absolute(options.root))
275
+ const outDir = absolute(options.outDir)
276
+ const runtimeDir = absolute(options.runtimeDir ?? defaultRuntimeDir())
277
+ if (outDir === root || outDir.startsWith(root + sep)) throw new Error(`acrylblends: the output folder ${outDir} lies inside the client folder ${root}`)
278
+ const written: string[] = []
279
+ for (const path of sourceFiles(root)) {
280
+ const source = join(root, ...path.split('/'))
281
+ const target = join(outDir, ...path.split('/'))
282
+ mkdirSync(dirname(target), { recursive: true })
283
+ if (COMPILED.has(extname(path))) writeFileSync(target, compileForBrowser(source, path))
284
+ else copyFileSync(source, target)
285
+ written.push(path)
286
+ }
287
+ mkdirSync(join(outDir, RUNTIME_DIR), { recursive: true })
288
+ for (const name of RUNTIME_FILES) {
289
+ copyFileSync(join(runtimeDir, name), join(outDir, RUNTIME_DIR, name))
290
+ written.push(`${RUNTIME_DIR}/${name}`)
291
+ }
292
+ return written
293
+ }
@@ -7,73 +7,15 @@
7
7
  * `typescript` is loaded from the app, on the first JSX file only; the framework itself has no dependencies.
8
8
  */
9
9
 
10
- import { createRequire, registerHooks } from 'node:module'
11
- import { existsSync, readFileSync } from 'node:fs'
12
- import { dirname } from 'node:path'
10
+ import { registerHooks } from 'node:module'
11
+ import { readFileSync } from 'node:fs'
13
12
  import { fileURLToPath } from 'node:url'
13
+ import { compileModule } from './transpile.ts'
14
14
 
15
15
  /** The JSX runtime the docs set up; an app's tsconfig.json (or a file's own `@jsxImportSource` pragma) chooses another. */
16
16
  const DEFAULT_IMPORT_SOURCE = 'acrylblends/jsx'
17
17
  const JSX_FILE = /\.[jt]sx$/u
18
18
 
19
- /** The part of the TypeScript compiler API the hook uses. */
20
- interface Compiler {
21
- transpileModule(input: string, options: { fileName: string, compilerOptions: Record<string, unknown> }): { outputText: string }
22
- findConfigFile(searchPath: string, fileExists: (path: string) => boolean): string | undefined
23
- readConfigFile(path: string, readFile: (path: string) => string | undefined): { config?: unknown }
24
- parseJsonConfigFileContent(json: unknown, host: unknown, basePath: string): { options: { jsxImportSource?: string } }
25
- sys: { fileExists(path: string): boolean, readFile(path: string): string | undefined }
26
- }
27
-
28
- const compilers = new Map<string, Compiler>()
29
- const importSources = new Map<string, string>()
30
-
31
- /** The app's own `typescript`, resolved from the JSX file, so each app compiles with the version it pinned. */
32
- function compilerFor(file: string): Compiler {
33
- let path: string
34
- try {
35
- path = createRequire(file).resolve('typescript')
36
- } catch {
37
- throw new Error(`acrylblends: ${file} contains JSX, and compiling it needs the typescript package in this app: npm install --save-dev typescript`)
38
- }
39
- let compiler = compilers.get(path)
40
- if (compiler === undefined) {
41
- compiler = createRequire(path)(path) as Compiler
42
- compilers.set(path, compiler)
43
- }
44
- return compiler
45
- }
46
-
47
- /** `jsxImportSource` of the tsconfig.json nearest to the file (following `extends`), read once per config. */
48
- function importSourceFor(file: string, ts: Compiler): string {
49
- const config = ts.findConfigFile(dirname(file), path => existsSync(path))
50
- if (config === undefined) return DEFAULT_IMPORT_SOURCE
51
- let source = importSources.get(config)
52
- if (source === undefined) {
53
- const { config: json } = ts.readConfigFile(config, path => ts.sys.readFile(path))
54
- source = ts.parseJsonConfigFileContent(json ?? {}, ts.sys, dirname(config)).options.jsxImportSource ?? DEFAULT_IMPORT_SOURCE
55
- importSources.set(config, source)
56
- }
57
- return source
58
- }
59
-
60
- /** A JSX module compiled to JavaScript, with its source map inline. */
61
- function compileJsx(file: string, text: string): string {
62
- const ts = compilerFor(file)
63
- return ts.transpileModule(text, {
64
- fileName: file,
65
- compilerOptions: {
66
- module: 'esnext',
67
- target: 'es2022',
68
- jsx: 'react-jsx',
69
- jsxImportSource: importSourceFor(file, ts),
70
- allowImportingTsExtensions: true,
71
- inlineSourceMap: true,
72
- inlineSources: true,
73
- },
74
- }).outputText
75
- }
76
-
77
19
  process.setSourceMapsEnabled(true)
78
20
 
79
21
  registerHooks({
@@ -81,6 +23,7 @@ registerHooks({
81
23
  // Every other module goes straight to Node: the hook's whole cost for them is this test.
82
24
  if (!url.startsWith('file:') || !JSX_FILE.test(new URL(url).pathname)) return nextLoad(url, context)
83
25
  const file = fileURLToPath(url)
84
- return { format: 'module', source: compileJsx(file, readFileSync(file, 'utf8')), shortCircuit: true }
26
+ const source = compileModule(file, readFileSync(file, 'utf8'), { defaultImportSource: DEFAULT_IMPORT_SOURCE, what: 'contains JSX' })
27
+ return { format: 'module', source, shortCircuit: true }
85
28
  },
86
29
  })