@shop-on/customer-runtime 0.1.0 → 0.2.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/dist/graphql.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { relayHeaders } from "./relay.js";
2
+ import { traceHeaders } from "./trace.js";
2
3
  import { evictStorefrontToken, getStorefrontToken } from "./oauth.js";
3
4
  import { ClientError, GraphQLClient } from "graphql-request";
4
5
  //#region src/graphql.ts
@@ -23,6 +24,7 @@ function buildClient(token, extra) {
23
24
  async function storefrontRequest(shopHandle, document, variables, options) {
24
25
  const extra = {
25
26
  ...options?.customerSessionToken ? { "X-Customer-Session": options.customerSessionToken } : {},
27
+ ...traceHeaders(),
26
28
  ...relayHeaders()
27
29
  };
28
30
  let token = await getStorefrontToken(shopHandle);
@@ -1,4 +1,5 @@
1
1
  import { relayHeaders } from "./relay.js";
2
+ import { traceHeaders } from "./trace.js";
2
3
  //#region src/internal-api.ts
3
4
  function apiUrl() {
4
5
  return process.env.SPN_CUSTOMER_API_URL ?? "http://localhost:3003";
@@ -40,6 +41,7 @@ async function internalPost(path, body, { signal } = {}) {
40
41
  headers: {
41
42
  "Content-Type": "application/json",
42
43
  "X-Internal-Auth": secret,
44
+ ...traceHeaders(),
43
45
  ...relayHeaders()
44
46
  },
45
47
  body: JSON.stringify(body),
@@ -59,6 +61,7 @@ async function internalDelete(path) {
59
61
  method: "DELETE",
60
62
  headers: {
61
63
  "X-Internal-Auth": secret,
64
+ ...traceHeaders(),
62
65
  ...relayHeaders()
63
66
  }
64
67
  });
package/dist/relay.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { currentRequest } from "./request-scope.js";
2
- import { isIP } from "node:net";
3
2
  //#region src/relay.ts
3
+ const IPV4_OCTET = "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
4
+ const IPV4 = new RegExp(`^${IPV4_OCTET}(\\.${IPV4_OCTET}){3}$`);
5
+ function isIP(value) {
6
+ if (IPV4.test(value)) return true;
7
+ return /^[\da-f:.]+$/i.test(value) && value.includes(":") && URL.canParse(`http://[${value}]`);
8
+ }
4
9
  /**
5
10
  * `X-Buyer-IP` from Cloudflare's `CF-Connecting-IP` on the request this call
6
11
  * runs under, plus `X-Relay-Auth` when SPN_RELAY_SECRET is set. Empty outside
@@ -2,7 +2,7 @@
2
2
  const EVENT_STORAGE_KEY = Symbol.for("tanstack-start:event-storage");
3
3
  /** The request this call runs under; undefined outside one (browser, tests, pre-handler). */
4
4
  function currentRequest() {
5
- return globalThis[EVENT_STORAGE_KEY]?.getStore()?.h3Event?.req;
5
+ return globalThis[EVENT_STORAGE_KEY]?.getStore()?.request;
6
6
  }
7
7
  const slots = /* @__PURE__ */ new WeakMap();
8
8
  /**
@@ -0,0 +1,27 @@
1
+ //#region src/sealed-session.d.ts
2
+ export interface SealedSessionConfig {
3
+ name: string;
4
+ /** At least 32 chars. */
5
+ password: string;
6
+ /** Seconds. Seal TTL plus an absolute cap counted from the first write. */
7
+ maxAge?: number;
8
+ cookie: {
9
+ secure: boolean;
10
+ httpOnly: true;
11
+ sameSite: "lax";
12
+ path: "/";
13
+ /** Seconds. Sent as Max-Age, refreshed on every write. */
14
+ maxAge?: number;
15
+ };
16
+ }
17
+ export interface SealedSession<T> {
18
+ /** Live: reflects every write committed so far in this request. */
19
+ readonly data: Partial<T>;
20
+ update(patch: Partial<T>): Promise<void>;
21
+ clear(): Promise<void>;
22
+ }
23
+ export declare class SessionTooLargeError extends Error {
24
+ constructor(name: string, length: number);
25
+ }
26
+ export declare function openSession<T>(config: SealedSessionConfig): Promise<SealedSession<T>>;
27
+ //#endregion
@@ -0,0 +1,154 @@
1
+ import { requestSlot } from "./request-scope.js";
2
+ import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server";
3
+ import { defaults, seal, unseal } from "iron-webcrypto";
4
+ //#region src/sealed-session.ts
5
+ /**
6
+ * Encrypted cookie sessions, wire-compatible with the `useSession` TanStack
7
+ * Start 1.168 shipped (h3 2.0.1-rc.20) and 1.169 removed. Both seal
8
+ * `{ id, createdAt, data }` with iron (Fe26.2, iron's defaults), so a cookie
9
+ * either one wrote opens in the other — no sign-out on deploy, and a rollback
10
+ * stays safe. `test/fixtures/h3-sessions.json` holds cookies Start 1.168
11
+ * minted; `test/sealed-session.test.ts` keeps them opening.
12
+ *
13
+ * Where it deliberately differs from h3:
14
+ * - A read never writes. h3 minted and set an empty session on every
15
+ * cookieless read.
16
+ * - `clear()` sticks for the rest of the request. h3 re-read the stale
17
+ * request cookie afterwards.
18
+ * - Writes are one cookie, never chunked. A seal over 3,800 chars throws
19
+ * `SessionTooLargeError` instead. h3's chunked cookies still open, and
20
+ * the next write or clear deletes their chunks.
21
+ * - No `x-<name>-session` request header.
22
+ *
23
+ * Each request shares one state per cookie name (`requestSlot`), so parallel
24
+ * opens unseal once, and writes queue: each merges onto the state the one
25
+ * before it committed, in call order.
26
+ */
27
+ var SessionTooLargeError = class extends Error {
28
+ constructor(name, length) {
29
+ super(`Session "${name}" seals to ${length} chars, over the ${MAX_SEALED_LENGTH} limit`);
30
+ this.name = "SessionTooLargeError";
31
+ }
32
+ };
33
+ const MAX_SEALED_LENGTH = 3800;
34
+ const CHUNKED = "__chunked__";
35
+ const MAX_CHUNKS = 100;
36
+ const DEFAULT_COOKIE = {
37
+ path: "/",
38
+ secure: true,
39
+ httpOnly: true
40
+ };
41
+ const empty = () => ({
42
+ id: "",
43
+ createdAt: 0,
44
+ data: {}
45
+ });
46
+ async function openSession(config) {
47
+ if (typeof config.password !== "string" || config.password.length < 32) throw new Error(`Session "${config.name}" needs a password of at least 32 characters`);
48
+ const init = () => {
49
+ const slot = {
50
+ chain: Promise.resolve(),
51
+ state: empty()
52
+ };
53
+ slot.chain = read(config).then((state) => {
54
+ slot.state = state;
55
+ });
56
+ return slot;
57
+ };
58
+ const slot = requestSlot(Symbol.for(`shop-on:sealed-session:${config.name}`), init) ?? init();
59
+ await slot.chain;
60
+ const enqueue = (run) => {
61
+ const result = slot.chain.then(run);
62
+ slot.chain = result.catch(() => {});
63
+ return result;
64
+ };
65
+ return {
66
+ get data() {
67
+ return slot.state.data;
68
+ },
69
+ update: (patch) => enqueue(() => write(config, slot, patch)),
70
+ clear: () => enqueue(() => {
71
+ slot.state = empty();
72
+ deleteCookie(config.name, {
73
+ ...DEFAULT_COOKIE,
74
+ ...config.cookie
75
+ });
76
+ deleteChunks(config);
77
+ })
78
+ };
79
+ }
80
+ function sealOptions(config) {
81
+ return {
82
+ ...defaults,
83
+ ttl: config.maxAge ? config.maxAge * 1e3 : 0
84
+ };
85
+ }
86
+ async function read(config) {
87
+ const sealed = readSealed(config.name);
88
+ if (!sealed) return empty();
89
+ try {
90
+ const opened = await unseal(sealed, config.password, sealOptions(config));
91
+ if (typeof opened?.id !== "string" || !opened.id || typeof opened.createdAt !== "number") return empty();
92
+ if (config.maxAge && Date.now() - opened.createdAt > config.maxAge * 1e3) return empty();
93
+ const data = opened.data && typeof opened.data === "object" ? { ...opened.data } : {};
94
+ return {
95
+ id: opened.id,
96
+ createdAt: opened.createdAt,
97
+ data
98
+ };
99
+ } catch {
100
+ return empty();
101
+ }
102
+ }
103
+ /** The request's sealed value, reassembled from h3's chunks when it split one. */
104
+ function readSealed(name) {
105
+ const value = getCookie(name);
106
+ const count = chunkCount(value);
107
+ if (!count) return value?.startsWith(CHUNKED) ? void 0 : value;
108
+ const chunks = [];
109
+ for (let i = 1; i <= count; i++) {
110
+ const chunk = getCookie(`${name}.${i}`);
111
+ if (!chunk) return void 0;
112
+ chunks.push(chunk);
113
+ }
114
+ return chunks.join("");
115
+ }
116
+ function chunkCount(value) {
117
+ if (!value?.startsWith(CHUNKED)) return 0;
118
+ const count = Number.parseInt(value.slice(11), 10);
119
+ return Number.isInteger(count) && count >= 1 && count <= MAX_CHUNKS ? count : 0;
120
+ }
121
+ function deleteChunks(config) {
122
+ const count = chunkCount(getCookie(config.name));
123
+ for (let i = 1; i <= count; i++) deleteCookie(`${config.name}.${i}`, {
124
+ ...DEFAULT_COOKIE,
125
+ ...config.cookie
126
+ });
127
+ }
128
+ async function write(config, slot, patch) {
129
+ const previous = slot.state;
130
+ const fresh = !previous.id || Object.keys(previous.data).length === 0;
131
+ const data = JSON.parse(JSON.stringify({
132
+ ...previous.data,
133
+ ...patch
134
+ }));
135
+ const next = fresh ? {
136
+ id: globalThis.crypto.randomUUID(),
137
+ createdAt: Date.now(),
138
+ data
139
+ } : {
140
+ ...previous,
141
+ data
142
+ };
143
+ const sealed = await seal(next, config.password, sealOptions(config));
144
+ if (sealed.length > MAX_SEALED_LENGTH) throw new SessionTooLargeError(config.name, sealed.length);
145
+ slot.state = next;
146
+ setCookie(config.name, sealed, {
147
+ ...DEFAULT_COOKIE,
148
+ expires: config.maxAge ? new Date(next.createdAt + config.maxAge * 1e3) : void 0,
149
+ ...config.cookie
150
+ });
151
+ deleteChunks(config);
152
+ }
153
+ //#endregion
154
+ export { SessionTooLargeError, openSession };
@@ -8,9 +8,9 @@
8
8
  * `undefined` for both "missing" and "wrong shop" so callers handle them
9
9
  * uniformly (treat as logged-out).
10
10
  *
11
- * The returned `ShopBoundCookieConfig` is shape-compatible with TanStack
12
- * Start's `SessionConfig` — pass it straight to `useSession`. We don't
13
- * import the TanStack type here so the package stays runtime-agnostic.
11
+ * The returned `ShopBoundCookieConfig` is shape-compatible with
12
+ * `SealedSessionConfig` — pass it straight to `openSession`
13
+ * (`./sealed-session`). This module stays free of TanStack imports.
14
14
  */
15
15
  export interface ShopBoundSession {
16
16
  shopHandle: string;
@@ -0,0 +1,13 @@
1
+ //#region src/trace.d.ts
2
+ export interface TraceOptions {
3
+ /** Continue a valid incoming `traceparent`. Only behind a proxy that started the trace (Traefik). */
4
+ continueIncoming?: boolean;
5
+ }
6
+ /**
7
+ * `traceparent` for the request this call runs under: one minted once per
8
+ * request, or with `continueIncoming` the incoming ids when valid; always
9
+ * sampled. Empty outside a request. Send it to our own api only, never to a
10
+ * third party.
11
+ */
12
+ export declare function traceHeaders({ continueIncoming }?: TraceOptions): Record<string, string>;
13
+ //#endregion
package/dist/trace.js ADDED
@@ -0,0 +1,25 @@
1
+ import { currentRequest, requestSlot } from "./request-scope.js";
2
+ //#region src/trace.ts
3
+ const TRACEPARENT = /^00-((?!0{32})[\da-f]{32})-((?!0{16})[\da-f]{16})-[\da-f]{2}$/;
4
+ const MINTED = Symbol("minted traceparent");
5
+ function randomHex(bytes) {
6
+ const buf = crypto.getRandomValues(new Uint8Array(bytes));
7
+ if (buf.every((b) => b === 0)) buf[0] = 1;
8
+ return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
9
+ }
10
+ /**
11
+ * `traceparent` for the request this call runs under: one minted once per
12
+ * request, or with `continueIncoming` the incoming ids when valid; always
13
+ * sampled. Empty outside a request. Send it to our own api only, never to a
14
+ * third party.
15
+ */
16
+ function traceHeaders({ continueIncoming = false } = {}) {
17
+ if (continueIncoming) {
18
+ const incoming = currentRequest()?.headers.get("traceparent")?.match(TRACEPARENT);
19
+ if (incoming) return { traceparent: `00-${incoming[1]}-${incoming[2]}-01` };
20
+ }
21
+ const traceparent = requestSlot(MINTED, () => `00-${randomHex(16)}-${randomHex(8)}-01`);
22
+ return traceparent ? { traceparent } : {};
23
+ }
24
+ //#endregion
25
+ export { traceHeaders };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shop-on/customer-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "SEE LICENSE IN dist/LICENSE.md",
5
5
  "files": [
6
6
  "dist"
@@ -14,23 +14,29 @@
14
14
  "./oauth": "./dist/oauth.js",
15
15
  "./relay": "./dist/relay.js",
16
16
  "./request-scope": "./dist/request-scope.js",
17
+ "./sealed-session": "./dist/sealed-session.js",
17
18
  "./session-cookie": "./dist/session-cookie.js",
18
19
  "./shop-context": "./dist/shop-context.js",
20
+ "./trace": "./dist/trace.js",
19
21
  "./package.json": "./package.json"
20
22
  },
21
23
  "dependencies": {
22
- "gql.tada": "1.11.3",
23
- "graphql-request": "7.4.0"
24
+ "gql.tada": "1.11.4",
25
+ "graphql-request": "7.4.0",
26
+ "iron-webcrypto": "2.0.0"
24
27
  },
25
28
  "devDependencies": {
26
- "@tanstack/react-start": "1.168.60",
27
- "@types/node": "26.6.3",
29
+ "@tanstack/react-start": "1.169.3",
30
+ "@types/node": "26.6.5",
28
31
  "typescript": "7.0.2",
29
- "vitest": "5.0.3"
32
+ "vitest": "5.0.5"
33
+ },
34
+ "peerDependencies": {
35
+ "@tanstack/react-start": "~1.169.3"
30
36
  },
31
37
  "scripts": {
32
38
  "check-types": "tsc --noEmit",
33
- "test": "vitest run",
39
+ "test": "vitest run --maxWorkers=2",
34
40
  "test:watch": "vitest"
35
41
  }
36
42
  }