@pylonsync/functions 0.3.310 → 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.
- package/dist/server-only.d.ts +20 -0
- package/dist/ssr-client-bundler.d.ts +10 -0
- package/dist/ssr-runtime.d.ts +9 -0
- package/package.json +5 -1
- package/src/server-only.ts +20 -0
- package/src/ssr-client-bundler.test.ts +58 -0
- package/src/ssr-client-bundler.ts +95 -0
- package/src/ssr-form-runtime.ts +4 -2
- package/src/ssr-runtime.test.ts +66 -0
- package/src/ssr-runtime.ts +25 -11
|
@@ -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/dist/ssr-runtime.d.ts
CHANGED
|
@@ -101,6 +101,7 @@ export interface SsrCookieOptions {
|
|
|
101
101
|
expires?: Date | string;
|
|
102
102
|
/** Defaults to true (secure default). Pass false for a client-readable cookie. */
|
|
103
103
|
httpOnly?: boolean;
|
|
104
|
+
/** Defaults to true in prod / false in dev. Forced true when sameSite="none". */
|
|
104
105
|
secure?: boolean;
|
|
105
106
|
/** Defaults to "lax". */
|
|
106
107
|
sameSite?: "strict" | "lax" | "none";
|
|
@@ -341,6 +342,14 @@ export declare function applyAutoSocialImages(component: string, headers: Record
|
|
|
341
342
|
* marker + probe are in place before the deferred client entry boots the sync
|
|
342
343
|
* engine. `<` is escaped so the JSON can't break out of the script.
|
|
343
344
|
*/
|
|
345
|
+
/**
|
|
346
|
+
* Escape a JSON string so it can't break out of a `<script>` element: `<`
|
|
347
|
+
* closes a `</script>` (or `<!--`, `<script`); U+2028/U+2029 are JS statement
|
|
348
|
+
* terminators (valid in JSON, not in a JS string literal). Applied to every
|
|
349
|
+
* server-serialized blob embedded in a script tag \u2014 the `application/json`
|
|
350
|
+
* data blobs AND the executable fallback warning.
|
|
351
|
+
*/
|
|
352
|
+
export declare function escapeScriptJson(json: string): string;
|
|
344
353
|
export declare function buildDevHudChunk(devInfo: Record<string, unknown>): string;
|
|
345
354
|
/**
|
|
346
355
|
* Build the hydration tail appended after React's stream EOFs: the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/functions",
|
|
3
|
-
"version": "0.3.
|
|
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
|
package/src/ssr-form-runtime.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
PylonRouteControl,
|
|
20
20
|
finalizeHeaders,
|
|
21
21
|
importModule,
|
|
22
|
+
isDevMode,
|
|
22
23
|
type ResponseState,
|
|
23
24
|
} from "./ssr-runtime";
|
|
24
25
|
import { buildDbWriter } from "./runtime";
|
|
@@ -81,8 +82,9 @@ function makeFormFields(raw: Record<string, string | string[]>): FormFields {
|
|
|
81
82
|
// the list drives the 405 `Allow` header advertising which a route.ts exports.
|
|
82
83
|
const HANDLER_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
|
83
84
|
const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64");
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
// Share ONE dev-mode parse with ssr-runtime (case-insensitive "1"/"true") so
|
|
86
|
+
// the form path and the render path never disagree on whether to leak stacks.
|
|
87
|
+
const isDev = isDevMode;
|
|
86
88
|
|
|
87
89
|
export async function handleForm(
|
|
88
90
|
msg: HandleFormMessage,
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
asRouteControl,
|
|
16
16
|
PylonRouteControl,
|
|
17
17
|
finalizeHeaders,
|
|
18
|
+
escapeScriptJson,
|
|
18
19
|
makeResponseController,
|
|
19
20
|
makeReadTrackingProxy,
|
|
20
21
|
makeRevocableReadTrackingProxy,
|
|
@@ -750,3 +751,68 @@ describe("isSafeRedirect — open-redirect guard for response.redirect()", () =>
|
|
|
750
751
|
expect(isSafeRedirect("https://app.example.com/x", {})).toBe(false);
|
|
751
752
|
});
|
|
752
753
|
});
|
|
754
|
+
|
|
755
|
+
describe("script-escaping + secure cookies", () => {
|
|
756
|
+
test("escapeScriptJson neutralizes a </script> breakout", () => {
|
|
757
|
+
const out = escapeScriptJson(JSON.stringify("</script><script>alert(1)</script>"));
|
|
758
|
+
expect(out).not.toContain("<");
|
|
759
|
+
expect(out).toContain("\\u003c");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("buildHydrationTail fallback warn script escapes manifestErr", () => {
|
|
763
|
+
const tail = buildHydrationTail({
|
|
764
|
+
component: "app/x/page",
|
|
765
|
+
layouts: [],
|
|
766
|
+
props: {},
|
|
767
|
+
ssrData: {},
|
|
768
|
+
manifestRoute: null,
|
|
769
|
+
publicPrefix: "/_pylon/build/",
|
|
770
|
+
manifestErr: 'no entry for "</script><script>evil</script>"',
|
|
771
|
+
});
|
|
772
|
+
const warn = tail.slice(tail.indexOf("console.warn"));
|
|
773
|
+
// The executable fallback must escape the error — no raw injected tag.
|
|
774
|
+
expect(warn).not.toContain("<script>evil");
|
|
775
|
+
expect(warn).toContain("\\u003c");
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("serializeCookie: Secure defaults + SameSite=None forces Secure", () => {
|
|
779
|
+
const mk = () => {
|
|
780
|
+
const s = { status: 200, headers: {}, cookies: [] as string[] };
|
|
781
|
+
return { s, r: makeResponseController(s) };
|
|
782
|
+
};
|
|
783
|
+
const prev = process.env.PYLON_DEV_MODE;
|
|
784
|
+
try {
|
|
785
|
+
// Prod (dev off): Secure defaults ON.
|
|
786
|
+
delete process.env.PYLON_DEV_MODE;
|
|
787
|
+
{
|
|
788
|
+
const { s, r } = mk();
|
|
789
|
+
r.setCookie("a", "1");
|
|
790
|
+
expect(s.cookies[0]).toContain("; Secure");
|
|
791
|
+
expect(s.cookies[0]).toContain("; HttpOnly");
|
|
792
|
+
}
|
|
793
|
+
// Explicit secure:false (non-None) wins even in prod.
|
|
794
|
+
{
|
|
795
|
+
const { s, r } = mk();
|
|
796
|
+
r.setCookie("a", "1", { secure: false });
|
|
797
|
+
expect(s.cookies[0]).not.toContain("; Secure");
|
|
798
|
+
}
|
|
799
|
+
// Dev: Secure defaults OFF so http://localhost keeps the cookie.
|
|
800
|
+
process.env.PYLON_DEV_MODE = "1";
|
|
801
|
+
{
|
|
802
|
+
const { s, r } = mk();
|
|
803
|
+
r.setCookie("a", "1");
|
|
804
|
+
expect(s.cookies[0]).not.toContain("; Secure");
|
|
805
|
+
}
|
|
806
|
+
// SameSite=None forces Secure even in dev (browsers drop it otherwise).
|
|
807
|
+
{
|
|
808
|
+
const { s, r } = mk();
|
|
809
|
+
r.setCookie("a", "1", { sameSite: "none" });
|
|
810
|
+
expect(s.cookies[0]).toContain("; Secure");
|
|
811
|
+
expect(s.cookies[0]).toContain("SameSite=None");
|
|
812
|
+
}
|
|
813
|
+
} finally {
|
|
814
|
+
if (prev === undefined) delete process.env.PYLON_DEV_MODE;
|
|
815
|
+
else process.env.PYLON_DEV_MODE = prev;
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -146,6 +146,7 @@ export interface SsrCookieOptions {
|
|
|
146
146
|
expires?: Date | string;
|
|
147
147
|
/** Defaults to true (secure default). Pass false for a client-readable cookie. */
|
|
148
148
|
httpOnly?: boolean;
|
|
149
|
+
/** Defaults to true in prod / false in dev. Forced true when sameSite="none". */
|
|
149
150
|
secure?: boolean;
|
|
150
151
|
/** Defaults to "lax". */
|
|
151
152
|
sameSite?: "strict" | "lax" | "none";
|
|
@@ -194,8 +195,13 @@ function serializeCookie(
|
|
|
194
195
|
c += `; Domain=${opts.domain}`;
|
|
195
196
|
}
|
|
196
197
|
if (opts.httpOnly !== false) c += `; HttpOnly`;
|
|
197
|
-
if (opts.secure) c += `; Secure`;
|
|
198
198
|
const ss = opts.sameSite ?? "lax";
|
|
199
|
+
// `Secure` defaults ON in prod (mirrors the framework session cookie), OFF in
|
|
200
|
+
// dev so an http://localhost cookie isn't dropped. `SameSite=None` REQUIRES
|
|
201
|
+
// `Secure` — browsers silently drop a None cookie without it — so force it
|
|
202
|
+
// there regardless of the caller / dev mode.
|
|
203
|
+
const secure = ss === "none" ? true : (opts.secure ?? !isDevMode());
|
|
204
|
+
if (secure) c += `; Secure`;
|
|
199
205
|
c += `; SameSite=${ss[0].toUpperCase()}${ss.slice(1)}`;
|
|
200
206
|
return c;
|
|
201
207
|
}
|
|
@@ -1517,11 +1523,22 @@ function pylonDevHud() {
|
|
|
1517
1523
|
* marker + probe are in place before the deferred client entry boots the sync
|
|
1518
1524
|
* engine. `<` is escaped so the JSON can't break out of the script.
|
|
1519
1525
|
*/
|
|
1520
|
-
|
|
1521
|
-
|
|
1526
|
+
/**
|
|
1527
|
+
* Escape a JSON string so it can't break out of a `<script>` element: `<`
|
|
1528
|
+
* closes a `</script>` (or `<!--`, `<script`); U+2028/U+2029 are JS statement
|
|
1529
|
+
* terminators (valid in JSON, not in a JS string literal). Applied to every
|
|
1530
|
+
* server-serialized blob embedded in a script tag \u2014 the `application/json`
|
|
1531
|
+
* data blobs AND the executable fallback warning.
|
|
1532
|
+
*/
|
|
1533
|
+
export function escapeScriptJson(json: string): string {
|
|
1534
|
+
return json
|
|
1522
1535
|
.replace(/</g, "\\u003c")
|
|
1523
1536
|
.replace(/\u2028/g, "\\u2028")
|
|
1524
1537
|
.replace(/\u2029/g, "\\u2029");
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
export function buildDevHudChunk(devInfo: Record<string, unknown>): string {
|
|
1541
|
+
const json = escapeScriptJson(JSON.stringify(devInfo));
|
|
1525
1542
|
return (
|
|
1526
1543
|
`<script id="__PYLON_DEV__" type="application/json">${json}</script>` +
|
|
1527
1544
|
`<script>(${pylonDevHud.toString()})();</script>`
|
|
@@ -1661,13 +1678,7 @@ export function buildHydrationTail(args: {
|
|
|
1661
1678
|
ssrData: args.ssrData,
|
|
1662
1679
|
};
|
|
1663
1680
|
if (args.kind) hydrationPayload.kind = args.kind;
|
|
1664
|
-
|
|
1665
|
-
// JS statement terminators). Regex form keeps the separators visible in
|
|
1666
|
-
// source rather than as invisible literals.
|
|
1667
|
-
const json = JSON.stringify(hydrationPayload)
|
|
1668
|
-
.replace(/</g, "\\u003c")
|
|
1669
|
-
.replace(/\u2028/g, "\\u2028")
|
|
1670
|
-
.replace(/\u2029/g, "\\u2029");
|
|
1681
|
+
const json = escapeScriptJson(JSON.stringify(hydrationPayload));
|
|
1671
1682
|
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
1672
1683
|
if (args.manifestRoute) {
|
|
1673
1684
|
// A cross-origin CDN module (`public_prefix` is an absolute http(s) URL) is
|
|
@@ -1677,7 +1688,10 @@ export function buildHydrationTail(args: {
|
|
|
1677
1688
|
const co = /^https?:\/\//i.test(args.publicPrefix) ? " crossorigin" : "";
|
|
1678
1689
|
tail += `<script type="module"${co} src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
|
|
1679
1690
|
} else {
|
|
1680
|
-
|
|
1691
|
+
// Executable script → the serialized message MUST be script-escaped
|
|
1692
|
+
// (`manifestErr` is server-derived today, but this is the one script sink
|
|
1693
|
+
// that would otherwise skip the `<`-escape every other blob applies).
|
|
1694
|
+
tail += `<script>console.warn(${escapeScriptJson(JSON.stringify(`[pylon ssr] hydration disabled: ${args.manifestErr}`))})</script>`;
|
|
1681
1695
|
}
|
|
1682
1696
|
if (isDevMode()) tail += DEV_LIVE_RELOAD_SNIPPET;
|
|
1683
1697
|
return tail;
|