@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "6.0.1",
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.39.5",
45
- "@jxsuite/catalog": "^0.1.2",
46
- "@jxsuite/collab": "^0.11.2",
47
- "@jxsuite/compiler": "^6.0.2",
48
- "@jxsuite/create": "^1.3.17",
49
- "@jxsuite/import": "^0.40.13",
50
- "@jxsuite/protocol": "^4.0.0",
51
- "@jxsuite/runtime": "^5.1.0",
52
- "@jxsuite/schema": "^4.0.0",
53
- "@jxsuite/site": "^2.1.0",
54
- "@jxsuite/starters": "^3.0.1",
55
- "chokidar": "^5.0.0",
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.73.0",
57
+ "oxfmt": "^0.74.0",
58
58
  "zod": "^4.6.7"
59
59
  },
60
60
  "devDependencies": {
61
- "@jxsuite/auth": "^0.7.0",
62
- "@jxsuite/connector": "^0.7.0",
63
- "@jxsuite/parser": "^4.0.1"
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: createDistMiddleware(root),
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(moduleAbsPath);
540
+ mod = await importModule(modulePath);
498
541
  } catch (error) {
499
- return new Response(`Failed to import "${$src}": ${errorMessage(error)}`, {
500
- status: 500,
501
- });
542
+ return { message: errorMessage(error), ok: false, stage: "import" };
502
543
  }
503
544
 
504
- const fn = mod[xport] ?? mod.default?.[xport];
545
+ const fn = mod[exportName] ?? mod.default?.[exportName];
505
546
  if (!isExportedFunction(fn)) {
506
- return new Response(`Export "${xport}" not found in "${$src}"`, {
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
- const result = await serverFn(args, projectDevEnv(projectRoot));
519
- return Response.json(result ?? null);
554
+ return { ok: true, value: await serverFn(args, env) };
520
555
  } catch (error) {
521
- return problem("internalError", errorMessage(error));
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, unlinkSync } from "node:fs";
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, which is the same shape
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 });