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.
- package/README.md +2 -2
- package/dist/index.js +75 -4
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +2 -1
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/api/summary.md +7 -4
- package/docs/framework/cordis/node-server.md +3 -0
- package/docs/framework/getting-started/deploy.md +133 -0
- package/docs/framework/getting-started/desktop.md +1 -1
- package/docs/framework/getting-started/nodejs.md +37 -16
- package/docs/framework/guides/client-modules.md +175 -0
- package/docs/framework/guides/forms-and-sessions.md +231 -0
- package/docs/framework/guides/jsx-dom.md +12 -7
- package/docs/framework/guides/validation.md +88 -1
- package/docs/framework/helpers/cookie.md +1 -1
- package/docs/framework/helpers/testing.md +17 -0
- package/docs/framework/upstream.json +3 -3
- package/lib/browser/client.js +307 -0
- package/lib/browser/jsx-dom.js +1432 -0
- package/lib/index.js +1 -25
- package/lib/node-server/register.js +7 -49
- package/lib/node-server.d.ts +74 -7
- package/lib/node-server.js +264 -7
- package/lib/schema-validator.d.ts +40 -14
- package/lib/schema-validator.js +2 -2
- package/lib/src-0hI7M7cN.js +27 -0
- package/lib/transpile-CTnWof4z.js +90 -0
- package/package.json +1 -1
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-node-server/src/adapter/server.ts +28 -5
- package/plugins/acryl-node-server/src/adapter/types.ts +14 -2
- package/plugins/acryl-node-server/src/client-modules.ts +293 -0
- package/plugins/acryl-node-server/src/register.ts +5 -62
- package/plugins/acryl-node-server/src/transpile.ts +120 -0
- package/plugins/acryl-node-server/upstream.json +4 -3
- package/plugins/acryl-router/src/browser/client.ts +3 -0
- package/plugins/acryl-router/src/browser/jsx-dom.ts +9 -0
- package/plugins/acryl-router/src/helpers/index.ts +1 -1
- package/plugins/acryl-router/src/helpers/schema-validator.ts +39 -12
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/desktop/plugin.json +1 -1
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/plugin.json +1 -1
- package/plugins/desktop-web/upstream.json +1 -1
- package/templates/agent/package.json +4 -3
- package/templates/blank/package.json +4 -3
- package/templates/todo/package.json +4 -3
- 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,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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
})
|