@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 +2 -0
- package/dist/internal-api.js +3 -0
- package/dist/relay.js +6 -1
- package/dist/request-scope.js +1 -1
- package/dist/sealed-session.d.ts +27 -0
- package/dist/sealed-session.js +154 -0
- package/dist/session-cookie.d.ts +3 -3
- package/dist/trace.d.ts +13 -0
- package/dist/trace.js +25 -0
- package/package.json +13 -7
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);
|
package/dist/internal-api.js
CHANGED
|
@@ -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
|
package/dist/request-scope.js
CHANGED
|
@@ -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()?.
|
|
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 };
|
package/dist/session-cookie.d.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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;
|
package/dist/trace.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
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.
|
|
27
|
-
"@types/node": "26.6.
|
|
29
|
+
"@tanstack/react-start": "1.169.3",
|
|
30
|
+
"@types/node": "26.6.5",
|
|
28
31
|
"typescript": "7.0.2",
|
|
29
|
-
"vitest": "5.0.
|
|
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
|
}
|