@jxsuite/server 6.0.1 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/dev.ts +95 -1
- package/src/resolve.ts +49 -14
- package/src/studio-api.ts +2 -100
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,25 +41,25 @@
|
|
|
41
41
|
"upgrade": "bun update --latest && bun install"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@jxsuite/ai": "^0.
|
|
45
|
-
"@jxsuite/catalog": "^0.1.
|
|
46
|
-
"@jxsuite/collab": "^0.11.
|
|
47
|
-
"@jxsuite/compiler": "^
|
|
48
|
-
"@jxsuite/create": "^1.
|
|
49
|
-
"@jxsuite/import": "^0.
|
|
50
|
-
"@jxsuite/protocol": "^
|
|
51
|
-
"@jxsuite/runtime": "^
|
|
52
|
-
"@jxsuite/schema": "^4.
|
|
53
|
-
"@jxsuite/site": "^2.1
|
|
54
|
-
"@jxsuite/starters": "^3.
|
|
55
|
-
"chokidar": "^
|
|
44
|
+
"@jxsuite/ai": "^0.41.0",
|
|
45
|
+
"@jxsuite/catalog": "^0.1.3",
|
|
46
|
+
"@jxsuite/collab": "^0.11.3",
|
|
47
|
+
"@jxsuite/compiler": "^7.0.0",
|
|
48
|
+
"@jxsuite/create": "^1.4.1",
|
|
49
|
+
"@jxsuite/import": "^0.41.1",
|
|
50
|
+
"@jxsuite/protocol": "^5.0.0",
|
|
51
|
+
"@jxsuite/runtime": "^6.0.0",
|
|
52
|
+
"@jxsuite/schema": "^4.1.0",
|
|
53
|
+
"@jxsuite/site": "^2.2.1",
|
|
54
|
+
"@jxsuite/starters": "^3.1.1",
|
|
55
|
+
"chokidar": "^6.0.0",
|
|
56
56
|
"kysely": "^0.29.7",
|
|
57
|
-
"oxfmt": "^0.
|
|
57
|
+
"oxfmt": "^0.74.0",
|
|
58
58
|
"zod": "^4.6.7"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@jxsuite/auth": "^0.
|
|
62
|
-
"@jxsuite/connector": "^0.
|
|
63
|
-
"@jxsuite/parser": "^4.
|
|
61
|
+
"@jxsuite/auth": "^0.8.1",
|
|
62
|
+
"@jxsuite/connector": "^0.8.1",
|
|
63
|
+
"@jxsuite/parser": "^4.1.1"
|
|
64
64
|
}
|
|
65
65
|
}
|
package/src/dev.ts
CHANGED
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { existsSync, statSync } from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
16
17
|
import { join, normalize, resolve, sep } from "node:path";
|
|
18
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
19
|
+
import type { JsonObject } from "@jxsuite/schema/types";
|
|
20
|
+
import { projectDevEnv } from "./dev-vars.ts";
|
|
21
|
+
import { problem } from "./problem.ts";
|
|
22
|
+
import { callServerExport } from "./resolve.ts";
|
|
17
23
|
import { createDevServer } from "./server.ts";
|
|
18
24
|
import { injectSSE } from "./watch.ts";
|
|
19
25
|
|
|
@@ -90,6 +96,84 @@ export function createDistMiddleware(
|
|
|
90
96
|
};
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
/** One server entry the last build collected: the export, and where the site worker imports it from. */
|
|
100
|
+
export interface ServerEntry {
|
|
101
|
+
exportName: string;
|
|
102
|
+
/** A `./` path from the project root, or a bare specifier, exactly as the worker bundle reads it. */
|
|
103
|
+
src: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The route every server function is served at, before the deployment base `server.ts` strips. */
|
|
107
|
+
const SERVER_FUNCTION_ROUTE = "/_jx/server/";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The arguments object a call posted. The worker reads its body the same way: anything that is not
|
|
111
|
+
* JSON is an empty argument object, so a function never sees the failure of a malformed request.
|
|
112
|
+
*/
|
|
113
|
+
async function readArguments(req: Request): Promise<JsonObject> {
|
|
114
|
+
try {
|
|
115
|
+
/* SAFETY: trust boundary. The body is whatever JSON the caller posted, and a compiled page posts
|
|
116
|
+
the entry's `arguments` object. The function receives it exactly as the worker passes it. */
|
|
117
|
+
return (await req.json()) as JsonObject;
|
|
118
|
+
} catch {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Middleware that stands in for the site worker's server-function routes (server.md §2): answers
|
|
125
|
+
* `POST /_jx/server/<export>` for each entry the last build collected, and `null` for anything
|
|
126
|
+
* else, so the request falls through to the next middleware.
|
|
127
|
+
*
|
|
128
|
+
* It answers as the worker does, because a built page calls both the same way: the function's
|
|
129
|
+
* return value as JSON, `null` for `undefined`, and `{ ok: false, error }` with status 500 when it
|
|
130
|
+
* cannot be called. The function receives `env` as the extension mounts do (`projectDevEnv`:
|
|
131
|
+
* `process.env` under the project's `.dev.vars`), so it reads the same secrets in development.
|
|
132
|
+
*
|
|
133
|
+
* Each module is resolved as the worker bundle resolves it, from the project root: a `./` path
|
|
134
|
+
* against the root, any other specifier through the project's own `package.json`. It applies no
|
|
135
|
+
* containment check, because the module path comes from the build and never from the request;
|
|
136
|
+
* containment stays in the `/__jx_server__` proxy, where the request names the module.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} root - Absolute project root
|
|
139
|
+
* @param {() => readonly ServerEntry[]} entries - The last build's collected entries, read per
|
|
140
|
+
* request
|
|
141
|
+
* @returns {(req: Request, url: URL) => Promise<Response | null>}
|
|
142
|
+
*/
|
|
143
|
+
export function createServerFunctionMiddleware(
|
|
144
|
+
root: string,
|
|
145
|
+
entries: () => readonly ServerEntry[],
|
|
146
|
+
): (req: Request, url: URL) => Promise<Response | null> {
|
|
147
|
+
const requireFromProject = createRequire(resolve(root, "package.json"));
|
|
148
|
+
return async (req, url) => {
|
|
149
|
+
if (req.method !== "POST" || !url.pathname.startsWith(SERVER_FUNCTION_ROUTE)) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const name = url.pathname.slice(SERVER_FUNCTION_ROUTE.length);
|
|
153
|
+
const entry = entries().find((candidate) => candidate.exportName === name);
|
|
154
|
+
if (!entry) {
|
|
155
|
+
return problem("notFound", `The last build collected no server function "${name}"`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let modulePath: string;
|
|
159
|
+
try {
|
|
160
|
+
modulePath =
|
|
161
|
+
entry.src.startsWith("./") || entry.src.startsWith("../")
|
|
162
|
+
? resolve(root, entry.src)
|
|
163
|
+
: requireFromProject.resolve(entry.src);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return Response.json({ error: errorMessage(error), ok: false }, { status: 500 });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const args = await readArguments(req);
|
|
169
|
+
const result = await callServerExport(modulePath, entry.exportName, args, projectDevEnv(root));
|
|
170
|
+
if (result.ok) {
|
|
171
|
+
return Response.json(result.value ?? null);
|
|
172
|
+
}
|
|
173
|
+
return Response.json({ error: result.message, ok: false }, { status: 500 });
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
93
177
|
export interface DevServerHandle {
|
|
94
178
|
port: number;
|
|
95
179
|
stop: () => void;
|
|
@@ -106,10 +190,13 @@ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle
|
|
|
106
190
|
const isSite = existsSync(join(root, "project.json"));
|
|
107
191
|
|
|
108
192
|
let building = Promise.resolve();
|
|
193
|
+
// What the last build collected, which the server-function route answers from.
|
|
194
|
+
let serverEntries: readonly ServerEntry[] = [];
|
|
109
195
|
const rebuild = () => {
|
|
110
196
|
building = building.then(async () => {
|
|
111
197
|
const { buildSite } = await import("@jxsuite/compiler/site");
|
|
112
198
|
const result = await buildSite(root, { verbose: false });
|
|
199
|
+
({ serverEntries } = result);
|
|
113
200
|
if (result.errors.length > 0) {
|
|
114
201
|
for (const err of result.errors) {
|
|
115
202
|
console.error(` build error: ${err}`);
|
|
@@ -124,6 +211,13 @@ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle
|
|
|
124
211
|
await rebuild();
|
|
125
212
|
}
|
|
126
213
|
|
|
214
|
+
// The built pages call the site worker's routes, and `jx dev` has no worker, so it answers them
|
|
215
|
+
// From the entries the last build collected, ahead of the built pages.
|
|
216
|
+
const serverFunctions = createServerFunctionMiddleware(root, () => serverEntries);
|
|
217
|
+
const distPages = createDistMiddleware(root);
|
|
218
|
+
const siteMiddleware = async (req: Request, url: URL) =>
|
|
219
|
+
(await serverFunctions(req, url)) ?? (await distPages(req, url));
|
|
220
|
+
|
|
127
221
|
/* SAFETY: createDevServer always calls Bun.serve with a hostname and TCP port, never a unix
|
|
128
222
|
socket, so the returned Server's `port` (typed `number | undefined` for the socket case) is a
|
|
129
223
|
number; its promise-returning `stop()` satisfies `() => void`. */
|
|
@@ -131,7 +225,7 @@ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle
|
|
|
131
225
|
port,
|
|
132
226
|
root,
|
|
133
227
|
...(isSite && {
|
|
134
|
-
middleware:
|
|
228
|
+
middleware: siteMiddleware,
|
|
135
229
|
watch: { preReload: () => rebuild(), reloadOnAnyChange: true },
|
|
136
230
|
}),
|
|
137
231
|
})) as DevServerHandle;
|
package/src/resolve.ts
CHANGED
|
@@ -492,33 +492,68 @@ export async function handleServerFunction(req: Request, root: string, projectRo
|
|
|
492
492
|
return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
+
// Match the production contract `fn(args, env)` (compiler emits `fn(args, c.env)`): the dev proxy
|
|
496
|
+
// Runs server-side, so its `env` is the extension mounts' own, `process.env` merged under the
|
|
497
|
+
// Project's `.dev.vars` plus `JX_PROJECT_ROOT` (server.md §3.3).
|
|
498
|
+
const result = await callServerExport(moduleAbsPath, xport, args, projectDevEnv(projectRoot));
|
|
499
|
+
if (result.ok) {
|
|
500
|
+
return Response.json(result.value ?? null);
|
|
501
|
+
}
|
|
502
|
+
if (result.stage === "import") {
|
|
503
|
+
return new Response(`Failed to import "${$src}": ${result.message}`, { status: 500 });
|
|
504
|
+
}
|
|
505
|
+
if (result.stage === "export") {
|
|
506
|
+
return new Response(`Export "${xport}" not found in "${$src}"`, { status: 500 });
|
|
507
|
+
}
|
|
508
|
+
return problem("internalError", result.message);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** What {@link callServerExport} answers: the function's value, or the stage that failed and why. */
|
|
512
|
+
export type ServerExportResult =
|
|
513
|
+
| { ok: true; value: ServerFunctionResult }
|
|
514
|
+
| { ok: false; stage: "import" | "export" | "call"; message: string };
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Import a module, read one export and call it as `fn(args, env)`, the contract a `timing:
|
|
518
|
+
* "server"` function has in production (compiler.md §6.3).
|
|
519
|
+
*
|
|
520
|
+
* The one place a dev host runs a server function, so the `/__jx_server__` proxy and the site
|
|
521
|
+
* worker's stand-in route in `jx dev` agree on what an export is (a named export, else a member of
|
|
522
|
+
* a default export) and on where each failure belongs. It decides nothing about WHICH module may
|
|
523
|
+
* run: the proxy's caller names one in a request and checks containment first, while the stand-in
|
|
524
|
+
* route's modules come from the build, so each does its own checking before it gets here.
|
|
525
|
+
*
|
|
526
|
+
* @param {string} modulePath - Absolute path of the module, or a specifier already resolved
|
|
527
|
+
* @param {string} exportName - The export to call
|
|
528
|
+
* @param {JsonObject} args - The function's first argument
|
|
529
|
+
* @param {ProjectDevEnv} env - The function's second argument: the platform environment
|
|
530
|
+
* @returns {Promise<ServerExportResult>}
|
|
531
|
+
*/
|
|
532
|
+
export async function callServerExport(
|
|
533
|
+
modulePath: string,
|
|
534
|
+
exportName: string,
|
|
535
|
+
args: JsonObject,
|
|
536
|
+
env: ProjectDevEnv,
|
|
537
|
+
): Promise<ServerExportResult> {
|
|
495
538
|
let mod: ModuleNamespace;
|
|
496
539
|
try {
|
|
497
|
-
mod = await importModule(
|
|
540
|
+
mod = await importModule(modulePath);
|
|
498
541
|
} catch (error) {
|
|
499
|
-
return
|
|
500
|
-
status: 500,
|
|
501
|
-
});
|
|
542
|
+
return { message: errorMessage(error), ok: false, stage: "import" };
|
|
502
543
|
}
|
|
503
544
|
|
|
504
|
-
const fn = mod[
|
|
545
|
+
const fn = mod[exportName] ?? mod.default?.[exportName];
|
|
505
546
|
if (!isExportedFunction(fn)) {
|
|
506
|
-
return
|
|
507
|
-
status: 500,
|
|
508
|
-
});
|
|
547
|
+
return { message: `Export "${exportName}" not found`, ok: false, stage: "export" };
|
|
509
548
|
}
|
|
510
549
|
|
|
511
550
|
try {
|
|
512
|
-
/* Match the production contract `fn(args, env)` (compiler emits `fn(args, c.env)`): the dev
|
|
513
|
-
proxy runs server-side, so its `env` is the extension mounts' own, `process.env` merged under
|
|
514
|
-
the project's `.dev.vars` plus `JX_PROJECT_ROOT` (server.md §3.3). */
|
|
515
551
|
/* SAFETY: trust boundary. A `timing: "server"` module exports its functions with the signature
|
|
516
552
|
`fn(args, env)` and a JSON answer (spec.md §11.4); this one is a function, checked above. */
|
|
517
553
|
const serverFn = fn as ServerFunction;
|
|
518
|
-
|
|
519
|
-
return Response.json(result ?? null);
|
|
554
|
+
return { ok: true, value: await serverFn(args, env) };
|
|
520
555
|
} catch (error) {
|
|
521
|
-
return
|
|
556
|
+
return { message: errorMessage(error), ok: false, stage: "call" };
|
|
522
557
|
}
|
|
523
558
|
}
|
|
524
559
|
|
package/src/studio-api.ts
CHANGED
|
@@ -11,9 +11,8 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from
|
|
|
11
11
|
import { componentMetaFrom } from "@jxsuite/schema/component-meta";
|
|
12
12
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
13
13
|
import { mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
14
|
-
import { existsSync, readFileSync, statSync
|
|
14
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { LOCATION_ID_FILE } from "@jxsuite/protocol/routes";
|
|
17
16
|
import {
|
|
18
17
|
buildExtensionsPayload,
|
|
19
18
|
buildProjectExtensionRegistry,
|
|
@@ -568,102 +567,6 @@ export async function handleStudioApi(
|
|
|
568
567
|
}
|
|
569
568
|
}
|
|
570
569
|
|
|
571
|
-
// Find a project directory by name — searches $HOME for the first matching directory with a
|
|
572
|
-
// Project.json. Dev-mode workaround for when showDirectoryPicker() can't provide absolute paths.
|
|
573
|
-
if (path === "/__studio/find-project" && req.method === "GET") {
|
|
574
|
-
const name = url.searchParams.get("name");
|
|
575
|
-
if (!name) {
|
|
576
|
-
return problem("invalidRequest", "Missing name");
|
|
577
|
-
}
|
|
578
|
-
try {
|
|
579
|
-
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
580
|
-
if (!home) {
|
|
581
|
-
return Response.json({ path: null });
|
|
582
|
-
}
|
|
583
|
-
const glob = new Bun.Glob(`**/${name}/project.json`);
|
|
584
|
-
try {
|
|
585
|
-
for await (const match of glob.scan({ cwd: home, dot: false })) {
|
|
586
|
-
if (match.includes("node_modules") || match.includes(".Trash")) {
|
|
587
|
-
continue;
|
|
588
|
-
}
|
|
589
|
-
const abs = resolve(home, dirname(match));
|
|
590
|
-
return Response.json({ path: abs });
|
|
591
|
-
}
|
|
592
|
-
} catch {}
|
|
593
|
-
return Response.json({ path: null });
|
|
594
|
-
} catch (error) {
|
|
595
|
-
return problem("internalError", errorMessage(error));
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/*
|
|
600
|
-
* Recover the absolute path of a directory the browser picked with `showDirectoryPicker()`.
|
|
601
|
-
*
|
|
602
|
-
* A `FileSystemDirectoryHandle` exposes only `.name`, never a filesystem path (§8.2), and unlike
|
|
603
|
-
* /__studio/find-project there is no `project.json` to search for — the whole point is that the
|
|
604
|
-
* folder is empty. So the client tags the folder with a hidden LOCATION_ID_FILE holding a random
|
|
605
|
-
* id and asks us which directory carries that id.
|
|
606
|
-
*
|
|
607
|
-
* Identity lives in the file's CONTENTS, not its name: matching the id is exact where matching a
|
|
608
|
-
* path shape is only probable — two folders can share a basename, and a file left behind by a
|
|
609
|
-
* crashed session can still be on disk. A candidate whose contents do not equal `id` is skipped,
|
|
610
|
-
* so a stale tag can never redirect a create. The winning file is deleted here, the moment it has
|
|
611
|
-
* served its purpose, rather than being left for the client to tidy up.
|
|
612
|
-
*/
|
|
613
|
-
if (path === "/__studio/locate-directory" && req.method === "GET") {
|
|
614
|
-
const name = url.searchParams.get("name");
|
|
615
|
-
const id = url.searchParams.get("id");
|
|
616
|
-
// `name` is interpolated into a glob; the id is only ever compared, never used as a pattern.
|
|
617
|
-
if (!name || !id || !/^[a-f0-9]{32}$/.test(id) || !/^[^/\\]+$/.test(name)) {
|
|
618
|
-
return problem("invalidRequest", "Missing or invalid name/id");
|
|
619
|
-
}
|
|
620
|
-
try {
|
|
621
|
-
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
622
|
-
if (!home) {
|
|
623
|
-
return Response.json({ path: null });
|
|
624
|
-
}
|
|
625
|
-
/** The tagged directory when this candidate holds our id, else null. */
|
|
626
|
-
const claim = (dir: string): string | null => {
|
|
627
|
-
const file = resolve(dir, LOCATION_ID_FILE);
|
|
628
|
-
try {
|
|
629
|
-
if (readFileSync(file, "utf8").trim() !== id) {
|
|
630
|
-
return null;
|
|
631
|
-
}
|
|
632
|
-
} catch {
|
|
633
|
-
return null;
|
|
634
|
-
}
|
|
635
|
-
try {
|
|
636
|
-
unlinkSync(file);
|
|
637
|
-
} catch {
|
|
638
|
-
// Read-only or already gone; the client's own cleanup is the backstop.
|
|
639
|
-
}
|
|
640
|
-
return dir;
|
|
641
|
-
};
|
|
642
|
-
|
|
643
|
-
// The home directory itself is a legitimate pick, and no `**/<name>/` glob can match it.
|
|
644
|
-
const atHome = claim(home);
|
|
645
|
-
if (atHome) {
|
|
646
|
-
return Response.json({ path: atHome });
|
|
647
|
-
}
|
|
648
|
-
// `dot: true` — the tag is a dotfile, so a dot-blind scan would never see it.
|
|
649
|
-
const glob = new Bun.Glob(`**/${name}/${LOCATION_ID_FILE}`);
|
|
650
|
-
try {
|
|
651
|
-
for await (const match of glob.scan({ cwd: home, dot: true })) {
|
|
652
|
-
if (match.includes("node_modules") || match.includes(".Trash")) {
|
|
653
|
-
continue;
|
|
654
|
-
}
|
|
655
|
-
const claimed = claim(resolve(home, dirname(match)));
|
|
656
|
-
if (claimed) {
|
|
657
|
-
return Response.json({ path: claimed });
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
} catch {}
|
|
661
|
-
return Response.json({ path: null });
|
|
662
|
-
} catch (error) {
|
|
663
|
-
return problem("internalError", errorMessage(error));
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
570
|
// Discover site projects — find all project.json files under root
|
|
668
571
|
if (path === "/__studio/sites" && req.method === "GET") {
|
|
669
572
|
try {
|
|
@@ -781,8 +684,7 @@ export async function handleStudioApi(
|
|
|
781
684
|
await readFile(resolve(destPath, "project.json"), "utf8"),
|
|
782
685
|
) as ProjectConfig;
|
|
783
686
|
// Root-relative while the project happens to sit under the server root (the historical
|
|
784
|
-
// Shape every caller already handles); absolute otherwise
|
|
785
|
-
// /__studio/find-project returns for an external project.
|
|
687
|
+
// Shape every caller already handles); absolute otherwise.
|
|
786
688
|
const inRoot = containedPath(destPath, root) !== null;
|
|
787
689
|
opts.onProjectCreated?.(destPath);
|
|
788
690
|
return Response.json({ config, root: inRoot ? fwd(relative(root, destPath)) : destPath });
|