@pylonsync/functions 0.3.311 → 0.3.312

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.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Marker import that pins a module to the SERVER. Put it at the top of any
3
+ * module that must never reach the browser — one holding secrets, server
4
+ * config, or node-only APIs:
5
+ *
6
+ * import "@pylonsync/functions/server-only";
7
+ *
8
+ * export const stripeKey = process.env.STRIPE_SECRET_KEY!;
9
+ *
10
+ * Page (`page.tsx`) and layout (`layout.tsx`) modules — and everything they
11
+ * transitively import — are bundled for client hydration, so a plain literal or
12
+ * server config in that graph would ship to the browser. If a module marked
13
+ * with this import is pulled into a client-reachable page, the SSR client
14
+ * bundler REFUSES to build and names the offending importer (see the
15
+ * `pylon-server-only` plugin in `ssr-client-bundler.ts`).
16
+ *
17
+ * On the server this is an inert no-op. There is nothing to call — importing it
18
+ * is the whole contract.
19
+ */
20
+ export {};
@@ -14,6 +14,16 @@ interface BundleClientMessage {
14
14
  */
15
15
  app_dir?: string;
16
16
  }
17
+ /**
18
+ * Fail the CLIENT bundle when a `server-only` module is resolved — meaning it
19
+ * was pulled into a page/layout's client graph. Page/layout modules (and their
20
+ * transitive imports) are bundled for hydration, so a literal secret or server
21
+ * config in that graph would ship to the browser (the `process.env.*` `define`
22
+ * only neutralizes env reads). Authors mark such modules with
23
+ * `import "@pylonsync/functions/server-only"`; this turns an accidental client
24
+ * import into a loud build failure that names the offending importer.
25
+ */
26
+ export declare function assertNotServerOnly(specifier: string, importer: string): void;
17
27
  /**
18
28
  * Manifest schema. One entry per route, indexed by the same
19
29
  * project-relative component path the SSR side passes through.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.311",
3
+ "version": "0.3.312",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,6 +14,10 @@
14
14
  "types": "./dist/runtime.d.ts",
15
15
  "default": "./src/runtime.ts"
16
16
  },
17
+ "./server-only": {
18
+ "types": "./dist/server-only.d.ts",
19
+ "default": "./src/server-only.ts"
20
+ },
17
21
  "./client-bundler": {
18
22
  "types": "./dist/ssr-client-bundler.d.ts",
19
23
  "default": "./src/ssr-client-bundler.ts"
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Marker import that pins a module to the SERVER. Put it at the top of any
3
+ * module that must never reach the browser — one holding secrets, server
4
+ * config, or node-only APIs:
5
+ *
6
+ * import "@pylonsync/functions/server-only";
7
+ *
8
+ * export const stripeKey = process.env.STRIPE_SECRET_KEY!;
9
+ *
10
+ * Page (`page.tsx`) and layout (`layout.tsx`) modules — and everything they
11
+ * transitively import — are bundled for client hydration, so a plain literal or
12
+ * server config in that graph would ship to the browser. If a module marked
13
+ * with this import is pulled into a client-reachable page, the SSR client
14
+ * bundler REFUSES to build and names the offending importer (see the
15
+ * `pylon-server-only` plugin in `ssr-client-bundler.ts`).
16
+ *
17
+ * On the server this is an inert no-op. There is nothing to call — importing it
18
+ * is the whole contract.
19
+ */
20
+ export {};
@@ -24,6 +24,7 @@ import * as os from "node:os";
24
24
  import {
25
25
  buildClientBundle,
26
26
  buildTailwind,
27
+ assertNotServerOnly,
27
28
  type PylonBundleManifest,
28
29
  } from "./ssr-client-bundler";
29
30
  import { nearestBoundaryComponent } from "./ssr-client-boundary";
@@ -430,3 +431,60 @@ describe("Tailwind compile is concurrency-safe", () => {
430
431
  expect(stranded).toEqual([]);
431
432
  }, 20_000);
432
433
  });
434
+
435
+ describe("server-only guard (secrets can't leak into the client bundle)", () => {
436
+ test("assertNotServerOnly throws for server-only specifiers, passes others", () => {
437
+ expect(() =>
438
+ assertNotServerOnly("@pylonsync/functions/server-only", "app/page.tsx"),
439
+ ).toThrow(/server-only/i);
440
+ expect(() => assertNotServerOnly("server-only", "app/x/layout.tsx")).toThrow(
441
+ /server-only/i,
442
+ );
443
+ // The importer is named so the author can find the offending module.
444
+ expect(() => assertNotServerOnly("server-only", "app/secrets.ts")).toThrow(
445
+ /app\/secrets\.ts/,
446
+ );
447
+ // Ordinary imports pass untouched.
448
+ expect(() => assertNotServerOnly("react", "app/page.tsx")).not.toThrow();
449
+ expect(() => assertNotServerOnly("@/lib/utils", "app/page.tsx")).not.toThrow();
450
+ });
451
+
452
+ test("a real client build importing a server-only module FAILS via the guard", async () => {
453
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-server-only-"));
454
+ const entry = path.join(dir, "entry.ts");
455
+ fs.writeFileSync(entry, `import "server-only";\nexport const x = 1;\n`);
456
+ // Same plugin the bundler installs. `guardFired` proves the guard's
457
+ // onResolve ran for the marker (Bun runs plugin resolution BEFORE default
458
+ // resolution) — distinguishing a real guard rejection from an unrelated
459
+ // "module not found". Bun surfaces the plugin throw as a failed build.
460
+ let guardFired = false;
461
+ let failed = false;
462
+ try {
463
+ const result = await Bun.build({
464
+ entrypoints: [entry],
465
+ outdir: path.join(dir, "out"),
466
+ target: "browser",
467
+ plugins: [
468
+ {
469
+ name: "pylon-server-only",
470
+ setup(build: any) {
471
+ build.onResolve(
472
+ { filter: /^(@pylonsync\/functions\/server-only|server-only)$/ },
473
+ (args: any) => {
474
+ guardFired = true;
475
+ assertNotServerOnly(args.path, args.importer);
476
+ },
477
+ );
478
+ },
479
+ },
480
+ ],
481
+ } as any);
482
+ failed = !result.success;
483
+ } catch {
484
+ failed = true;
485
+ }
486
+ expect(guardFired).toBe(true);
487
+ expect(failed).toBe(true);
488
+ fs.rmSync(dir, { recursive: true, force: true });
489
+ });
490
+ });
@@ -101,10 +101,46 @@ declare const Bun: {
101
101
  };
102
102
  publicPath?: string;
103
103
  root?: string;
104
+ plugins?: Array<{
105
+ name: string;
106
+ setup(build: {
107
+ onResolve(
108
+ opts: { filter: RegExp; namespace?: string },
109
+ callback: (args: { path: string; importer: string }) => void,
110
+ ): void;
111
+ }): void;
112
+ }>;
104
113
  }): Promise<BunBuildOutput>;
105
114
  file(path: string): { exists(): Promise<boolean> };
106
115
  };
107
116
 
117
+ /**
118
+ * Specifiers marking a module as SERVER-ONLY: `@pylonsync/functions/server-only`
119
+ * (in-ecosystem) or the bare `server-only` (Next.js compat). A module that
120
+ * imports one must never reach the browser.
121
+ */
122
+ const SERVER_ONLY_RE = /^(@pylonsync\/functions\/server-only|server-only)$/;
123
+
124
+ /**
125
+ * Fail the CLIENT bundle when a `server-only` module is resolved — meaning it
126
+ * was pulled into a page/layout's client graph. Page/layout modules (and their
127
+ * transitive imports) are bundled for hydration, so a literal secret or server
128
+ * config in that graph would ship to the browser (the `process.env.*` `define`
129
+ * only neutralizes env reads). Authors mark such modules with
130
+ * `import "@pylonsync/functions/server-only"`; this turns an accidental client
131
+ * import into a loud build failure that names the offending importer.
132
+ */
133
+ export function assertNotServerOnly(specifier: string, importer: string): void {
134
+ if (SERVER_ONLY_RE.test(specifier)) {
135
+ throw new Error(
136
+ `pylon: "${specifier}" is server-only but was imported into the client bundle by ` +
137
+ `${importer || "a page/layout module"}. Page/layout modules (and everything they import) ship to ` +
138
+ `the browser — move server-only code (secrets, server config, node-only APIs) into a server function ` +
139
+ `(functions/) or a route.ts handler and pass only the rendered values as props.`,
140
+ );
141
+ }
142
+ }
143
+
108
144
  /**
109
145
  * Synchronously walk the route dir (`<appDirRel>` under cwd, e.g.
110
146
  * `app` or `web/app`) and return one entry per discovered page, each
@@ -1240,6 +1276,19 @@ async function _doBuildInner(
1240
1276
  chunk: "chunks/[name]-[hash].js",
1241
1277
  asset: "assets/[name]-[hash][ext]",
1242
1278
  },
1279
+ // Refuse to bundle a server-only module into a client-reachable page —
1280
+ // secrets / server config in a page's import graph would otherwise ship
1281
+ // to the browser.
1282
+ plugins: [
1283
+ {
1284
+ name: "pylon-server-only",
1285
+ setup(build) {
1286
+ build.onResolve({ filter: SERVER_ONLY_RE }, (args) => {
1287
+ assertNotServerOnly(args.path, args.importer);
1288
+ });
1289
+ },
1290
+ },
1291
+ ],
1243
1292
  });
1244
1293
 
1245
1294
  if (!result.success) {
@@ -1281,6 +1330,52 @@ async function _doBuildInner(
1281
1330
  }
1282
1331
  }
1283
1332
 
1333
+ // loro-crdt's web build locates its WASM sibling at RUNTIME via
1334
+ // `new URL("loro_wasm_bg.wasm", import.meta.url)` — the file never
1335
+ // appears in the static import graph, so Bun doesn't emit it and every
1336
+ // CRDT-using page 404s on /_pylon/build/loro_wasm_bg.wasm during
1337
+ // hydration. When any built output references the wasm by name, copy
1338
+ // the binary next to the entries AND into chunks/ so the runtime URL
1339
+ // resolves from either an entry or a split chunk.
1340
+ try {
1341
+ const referencesLoroWasm = result.outputs.some((o) => {
1342
+ if (!o.path.endsWith(".js")) return false;
1343
+ try {
1344
+ return fs.readFileSync(o.path, "utf8").includes("loro_wasm_bg.wasm");
1345
+ } catch {
1346
+ return false;
1347
+ }
1348
+ });
1349
+ if (referencesLoroWasm) {
1350
+ const loroPkg = (Bun as any).resolveSync(
1351
+ "loro-crdt/package.json",
1352
+ cwd,
1353
+ ) as string;
1354
+ // The wasm must come from the SAME build variant whose JS glue got
1355
+ // bundled — the wasm-bindgen import namespaces differ between
1356
+ // variants (mixing them fails instantiation with `Import #0 "wbg"`).
1357
+ // `target: "browser"` resolves the package's "browser" condition, so
1358
+ // prefer browser/; web/ is the fallback for older package layouts.
1359
+ const loroDir = path.dirname(loroPkg);
1360
+ const wasmSrc = ["browser", "web"]
1361
+ .map((v) => path.join(loroDir, v, "loro_wasm_bg.wasm"))
1362
+ .find((p) => fs.existsSync(p));
1363
+ if (wasmSrc) {
1364
+ fs.copyFileSync(wasmSrc, path.join(outdir, "loro_wasm_bg.wasm"));
1365
+ const chunksDir = path.join(outdir, "chunks");
1366
+ if (fs.existsSync(chunksDir)) {
1367
+ fs.copyFileSync(
1368
+ wasmSrc,
1369
+ path.join(chunksDir, "loro_wasm_bg.wasm"),
1370
+ );
1371
+ }
1372
+ }
1373
+ }
1374
+ } catch {
1375
+ // Best-effort: failing to copy just reproduces the 404 this guards
1376
+ // against; the build itself is fine.
1377
+ }
1378
+
1284
1379
  // Scan a built JS file for static `import` literals pointing
1285
1380
  // at `./chunks/<file>.js` and return them resolved to outdir-
1286
1381
  // relative paths. Bun's minified output uses simple double