@things-factory/board-service 10.1.107 → 10.1.112
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/controllers/headless-pool-for-label.d.ts +23 -0
- package/dist-server/controllers/headless-pool-for-label.js +32 -1
- package/dist-server/controllers/headless-pool-for-label.js.map +1 -1
- package/dist-server/controllers/headless-pool-for-label.test.js +44 -0
- package/dist-server/controllers/headless-pool-for-label.test.js.map +1 -1
- package/dist-server/controllers/label-command.js +3 -2
- package/dist-server/controllers/label-command.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
* Label Service Headless Pool
|
|
3
3
|
* Using the unified headless pool system from @things-factory/shell
|
|
4
4
|
*/
|
|
5
|
+
type LabelJob = {
|
|
6
|
+
subdomain?: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The headers a sub-request goes with: the job's user and domain, for a request to this server while a job runs;
|
|
11
|
+
* nothing for any other host, or between jobs.
|
|
12
|
+
*/
|
|
13
|
+
export declare function labelRequestHeaders(requestUrl: string, origin: string, job: LabelJob | undefined, existing: Record<string, string>): Record<string, string> | undefined;
|
|
14
|
+
/** Runs one label job on a page as the requesting user in their domain; the page forgets them when it is done. */
|
|
15
|
+
export declare function forLabelJob<T>(page: object, who: {
|
|
16
|
+
domain?: {
|
|
17
|
+
subdomain?: string;
|
|
18
|
+
};
|
|
19
|
+
user?: {
|
|
20
|
+
sign?: () => Promise<string> | string;
|
|
21
|
+
};
|
|
22
|
+
}, work: () => Promise<T>): Promise<T>;
|
|
23
|
+
export declare function setupLabelPage(browser: any): Promise<{
|
|
24
|
+
browser: any;
|
|
25
|
+
page: any;
|
|
26
|
+
}>;
|
|
5
27
|
/**
|
|
6
28
|
* Get the label headless pool
|
|
7
29
|
* @returns Pool instance with acquire/release methods
|
|
@@ -17,3 +39,4 @@ export declare function getHeadlessPool(): {
|
|
|
17
39
|
min: number;
|
|
18
40
|
clear: () => Promise<void>;
|
|
19
41
|
};
|
|
42
|
+
export {};
|
|
@@ -4,9 +4,36 @@
|
|
|
4
4
|
* Using the unified headless pool system from @things-factory/shell
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.labelRequestHeaders = labelRequestHeaders;
|
|
8
|
+
exports.forLabelJob = forLabelJob;
|
|
9
|
+
exports.setupLabelPage = setupLabelPage;
|
|
7
10
|
exports.getHeadlessPool = getHeadlessPool;
|
|
8
11
|
const shell_1 = require("@things-factory/shell");
|
|
9
12
|
const fonts_js_1 = require("./fonts.js");
|
|
13
|
+
const jobs = new WeakMap();
|
|
14
|
+
/**
|
|
15
|
+
* The headers a sub-request goes with: the job's user and domain, for a request to this server while a job runs;
|
|
16
|
+
* nothing for any other host, or between jobs.
|
|
17
|
+
*/
|
|
18
|
+
function labelRequestHeaders(requestUrl, origin, job, existing) {
|
|
19
|
+
if (!job || !requestUrl.startsWith(origin + '/'))
|
|
20
|
+
return undefined;
|
|
21
|
+
return {
|
|
22
|
+
...existing,
|
|
23
|
+
...(job.subdomain ? { 'x-things-factory-domain': job.subdomain } : {}),
|
|
24
|
+
...(job.token ? { Authorization: 'Bearer ' + job.token } : {})
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Runs one label job on a page as the requesting user in their domain; the page forgets them when it is done. */
|
|
28
|
+
async function forLabelJob(page, who, work) {
|
|
29
|
+
jobs.set(page, { subdomain: who.domain?.subdomain, token: (await who.user?.sign?.()) ?? undefined });
|
|
30
|
+
try {
|
|
31
|
+
return await work();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
jobs.delete(page);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
10
37
|
// Custom setup function for label page
|
|
11
38
|
async function setupLabelPage(browser) {
|
|
12
39
|
const protocol = 'http';
|
|
@@ -53,7 +80,11 @@ async function setupLabelPage(browser) {
|
|
|
53
80
|
});
|
|
54
81
|
}
|
|
55
82
|
else {
|
|
56
|
-
request.
|
|
83
|
+
const headers = labelRequestHeaders(request.url(), `${protocol}://${host}:${port}`, jobs.get(page), request.headers());
|
|
84
|
+
if (headers)
|
|
85
|
+
request.continue({ headers });
|
|
86
|
+
else
|
|
87
|
+
request.continue();
|
|
57
88
|
}
|
|
58
89
|
});
|
|
59
90
|
await page.goto(url, { timeout: 0, waitUntil: 'load' });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"headless-pool-for-label.js","sourceRoot":"","sources":["../../server/controllers/headless-pool-for-label.ts"],"names":[],"mappings":";AAAA;;;GAGG;;AAwHH,0CAYC;AAlID,iDAA4G;AAC5G,yCAAkC;AAElC,uCAAuC;AACvC,KAAK,UAAU,cAAc,CAAC,OAAY;IACxC,MAAM,QAAQ,GAAG,MAAM,CAAA;IACvB,MAAM,IAAI,GAAG,WAAW,CAAA;IACxB,MAAM,IAAI,GAAG,8BAA8B,CAAA;IAC3C,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAA;IAC7B,MAAM,GAAG,GAAG,GAAG,QAAQ,MAAM,IAAI,IAAI,IAAI,GAAG,IAAI,EAAE,CAAA;IAElD,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAA;IACpC,MAAM,CAAC,UAAU,EAAE,UAAU,CAAC,GAAG,MAAM,IAAA,gBAAK,GAAE,CAAA;IAE9C,MAAM,IAAI,CAAC,sBAAsB,CAAC,IAAI,CAAC,CAAA;IAEvC,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAC,GAAG,EAAC,EAAE;QAC7B,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,CAAA;QAE7F,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,EAAE,CAAC;YACpC,OAAO,CAAC,GAAG,CAAC,aAAa,GAAG,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAA;QAC/E,CAAC;aAAM,CAAC;YACN,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAE,CAAA;YACvB,IAAI,IAAI;gBAAE,OAAO,CAAC,GAAG,CAAC,aAAa,GAAG,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC,CAAA;QACzD,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,EAAE,CAAC,WAAW,EAAE,KAAK,CAAC,EAAE;QAC3B,OAAO,CAAC,KAAK,CAAC,wBAAwB,KAAK,CAAC,OAAO,EAAE,CAAC,CAAA;QACtD,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;IAC5B,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE;QACvB,OAAO,CAAC,KAAK,CAAC,oBAAoB,KAAK,EAAE,CAAC,CAAA;QAC1C,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;IAC5B,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,EAAE,CAAC,eAAe,EAAE,OAAO,CAAC,EAAE;QACjC,OAAO,CAAC,KAAK,CAAC,iBAAiB,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;IACjD,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE;QAC3B,IAAI,OAAO,CAAC,GAAG,EAAE,KAAK,GAAG,EAAE,CAAC;YAC1B,OAAO,CAAC,QAAQ,CAAC;gBACf,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE;oBACP,cAAc,EAAE,kBAAkB;iBACnC;gBACD,QAAQ,EAAE,IAAI,CAAC,SAAS,CAAC;oBACvB,KAAK,EAAE,UAAU;oBACjB,UAAU;iBACX,CAAC;aACH,CAAC,CAAA;QACJ,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,QAAQ,EAAE,CAAA;QACpB,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAA;IAEvD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAC1B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,cAAc,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,uBAAuB,IAAI,GAAG,EAAE,EAAE,CAAC,CAAA;AAC/E,MAAM,cAAc,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,uBAAuB,IAAI,IAAI,EAAE,EAAE,CAAC,CAAA;AAEhF;;;;;;;;;;;GAWG;AACH,IAAI,SAAiE,CAAA;AAErE,SAAS,IAAI;IACX,SAAS,KAAK,IAAA,+BAAuB,EAAC,OAAO,EAAE;QAC7C,GAAG,EAAE,cAAc;QACnB,GAAG,EAAE,cAAc;QACnB,IAAI,EAAE,CAAC,GAAG,mCAA2B,CAAC,KAAK,EAAE,GAAG,mCAA2B,CAAC,aAAa,CAAC;QAC1F,oBAAoB,EAAE,KAAK;QAC3B,YAAY,EAAE,IAAI;QAClB,aAAa,EAAE,IAAI;QACnB,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IACF,OAAO,SAAS,CAAA;AAClB,CAAC;AAED,IAAA,cAAM,EAAC,wBAAwB,EAAE,GAAG,EAAE;IACpC,IAAI,EAAE,CAAA;AACR,CAAC,CAAC,CAAA;AAEF,iCAAiC;AACjC,UAAU,CAAC,KAAK,IAAI,EAAE;IACpB,MAAM,WAAW,GAAG,cAAM,CAAC,SAAS,CAAC,qBAAqB,CAAC,CAAA;IAC3D,IAAI,KAAK,EAAE,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;QACrC,4FAA4F;QAC5F,MAAM,SAAS,EAAE,KAAK,EAAE,CAAA;IAC1B,CAAC;AACH,CAAC,CAAC,CAAA;AAEF;;;GAGG;AACH,SAAgB,eAAe;IAC7B,OAAO;QACL,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE;QAC/B,OAAO,EAAE,CAAC,QAAa,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC;QACpD,IAAI,EAAE,CAAC,EAAE,iDAAiD;QAC1D,SAAS,EAAE,CAAC;QACZ,QAAQ,EAAE,CAAC;QACX,OAAO,EAAE,CAAC;QACV,GAAG,EAAE,cAAc;QACnB,GAAG,EAAE,cAAc;QACnB,KAAK,EAAE,GAAG,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,CAAC,gCAAgC;KACjE,CAAA;AACH,CAAC","sourcesContent":["/**\n * Label Service Headless Pool\n * Using the unified headless pool system from @things-factory/shell\n */\n\nimport { getOrCreateHeadlessPool, HEADLESS_POOL_ARGUMENT_SETS, onHook, pubsub } from '@things-factory/shell'\nimport { fonts } from './fonts.js'\n\n// Custom setup function for label page\nasync function setupLabelPage(browser: any) {\n const protocol = 'http'\n const host = 'localhost'\n const path = '/internal-label-command-view'\n const port = process.env.PORT\n const url = `${protocol}://${host}:${port}${path}`\n\n const page = await browser.newPage()\n const [fontsToUse, fontStyles] = await fonts()\n\n await page.setRequestInterception(true)\n\n page.on('console', async msg => {\n const args = await Promise.all(msg.args().map(arg => arg.jsonValue().catch(() => undefined)))\n\n if (args.some(a => a !== undefined)) {\n console.log(`[headless ${msg.type()}]`, ...args.filter(a => a !== undefined))\n } else {\n const text = msg.text()\n if (text) console.log(`[headless ${msg.type()}]`, text)\n }\n })\n\n page.on('pageerror', error => {\n console.error(`[headless pageerror] ${error.message}`)\n console.error(error.stack)\n })\n\n page.on('error', error => {\n console.error(`[headless fault] ${error}`)\n console.error(error.stack)\n })\n\n page.on('requestfailed', request => {\n console.error('Request failed:', request.url())\n })\n\n page.on('request', request => {\n if (request.url() === url) {\n request.continue({\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n postData: JSON.stringify({\n fonts: fontsToUse,\n fontStyles\n })\n })\n } else {\n request.continue()\n }\n })\n\n await page.goto(url, { timeout: 0, waitUntil: 'load' })\n\n return { browser, page }\n}\n\n/**\n * 라벨 출력은 (자동 출고 워크플로우 등) 즉시성 요구 — cold start 비용 (browser+page\n * setup ~2-3 초) 이 사용자 경험에 직결. min=1 로 최소 1 개 warm 유지가 기본.\n * 사용 빈도가 매우 낮은 사이트는 환경 변수로 0 까지 낮출 수 있다.\n *\n * HEADLESS_POOL_LABEL_MIN (default 1)\n * HEADLESS_POOL_LABEL_MAX (default 10)\n */\nconst LABEL_POOL_MIN = parseInt(process.env.HEADLESS_POOL_LABEL_MIN ?? '1', 10)\nconst LABEL_POOL_MAX = parseInt(process.env.HEADLESS_POOL_LABEL_MAX ?? '10', 10)\n\n/*\n * The pool is made when the server has started, not when this module is loaded.\n *\n * With min=1 the pool builds a browser as soon as it exists, and the page setup reads fonts from the database and\n * loads a page from this same server. At module load neither is ready: shell's databaseInitializer is still running\n * synchronize(), and on sqlite every query shares the one query runner. The fonts query's release() cleared the\n * runner's loaded-table list mid-synchronize, which then created env_vars again and died on 'already exists' —\n * plant did not start after 10.1.38 (seen 2026-09-24; it showed then because ops-master made synchronize rebuild\n * five tables, a longer window). 'bootstrap-module-start' comes after the database and after the server listens.\n *\n * A caller that needs a label before that — none should, since routes are served only after it — still gets a pool.\n */\nlet labelPool: ReturnType<typeof getOrCreateHeadlessPool> | undefined\n\nfunction pool() {\n labelPool ??= getOrCreateHeadlessPool('label', {\n min: LABEL_POOL_MIN,\n max: LABEL_POOL_MAX,\n args: [...HEADLESS_POOL_ARGUMENT_SETS.basic, ...HEADLESS_POOL_ARGUMENT_SETS.keychain_safe],\n acquireTimeoutMillis: 15000,\n testOnBorrow: true,\n enableCleanup: true,\n customSetup: setupLabelPage\n })\n return labelPool\n}\n\nonHook('bootstrap-module-start', () => {\n pool()\n})\n\n// Font change event subscription\nsetTimeout(async () => {\n const eventSource = pubsub.subscribe('notify-font-changed')\n for await (const data of eventSource) {\n // Reset the pool when fonts change; a pool not made yet will read the new fonts when it is.\n await labelPool?.reset()\n }\n})\n\n/**\n * Get the label headless pool\n * @returns Pool instance with acquire/release methods\n */\nexport function getHeadlessPool() {\n return {\n acquire: () => pool().acquire(),\n release: (resource: any) => pool().release(resource),\n size: 0, // These will be dynamically calculated if needed\n available: 0,\n borrowed: 0,\n pending: 0,\n max: LABEL_POOL_MAX,\n min: LABEL_POOL_MIN,\n clear: () => labelPool?.reset() // For font change compatibility\n }\n}"]}
|
|
1
|
+
{"version":3,"file":"headless-pool-for-label.js","sourceRoot":"","sources":["../../server/controllers/headless-pool-for-label.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Label Service Headless Pool\n * Using the unified headless pool system from @things-factory/shell\n */\n\nimport { getOrCreateHeadlessPool, HEADLESS_POOL_ARGUMENT_SETS, onHook, pubsub } from '@things-factory/shell'\nimport { fonts } from './fonts.js'\n\n/*\n * Who a label page is working for, while it works (ADR-0106 decision 4). The page is shared by every domain, so who\n * it is for is set per job, never per page: a sub-request the page makes to this server -- an image, an attachment --\n * goes with that job's user and domain, as headless-render's do. Internal sub-requests: headers, not signed URLs.\n */\ntype LabelJob = { subdomain?: string; token?: string }\nconst jobs = new WeakMap<object, LabelJob>()\n\n/**\n * The headers a sub-request goes with: the job's user and domain, for a request to this server while a job runs;\n * nothing for any other host, or between jobs.\n */\nexport function labelRequestHeaders(requestUrl: string, origin: string, job: LabelJob | undefined, existing: Record<string, string>): Record<string, string> | undefined {\n if (!job || !requestUrl.startsWith(origin + '/')) return undefined\n return {\n ...existing,\n ...(job.subdomain ? { 'x-things-factory-domain': job.subdomain } : {}),\n ...(job.token ? { Authorization: 'Bearer ' + job.token } : {})\n }\n}\n\n/** Runs one label job on a page as the requesting user in their domain; the page forgets them when it is done. */\nexport async function forLabelJob<T>(page: object, who: { domain?: { subdomain?: string }; user?: { sign?: () => Promise<string> | string } }, work: () => Promise<T>): Promise<T> {\n jobs.set(page, { subdomain: who.domain?.subdomain, token: (await who.user?.sign?.()) ?? undefined })\n try {\n return await work()\n } finally {\n jobs.delete(page)\n }\n}\n\n// Custom setup function for label page\nexport async function setupLabelPage(browser: any) {\n const protocol = 'http'\n const host = 'localhost'\n const path = '/internal-label-command-view'\n const port = process.env.PORT\n const url = `${protocol}://${host}:${port}${path}`\n\n const page = await browser.newPage()\n const [fontsToUse, fontStyles] = await fonts()\n\n await page.setRequestInterception(true)\n\n page.on('console', async msg => {\n const args = await Promise.all(msg.args().map(arg => arg.jsonValue().catch(() => undefined)))\n\n if (args.some(a => a !== undefined)) {\n console.log(`[headless ${msg.type()}]`, ...args.filter(a => a !== undefined))\n } else {\n const text = msg.text()\n if (text) console.log(`[headless ${msg.type()}]`, text)\n }\n })\n\n page.on('pageerror', error => {\n console.error(`[headless pageerror] ${error.message}`)\n console.error(error.stack)\n })\n\n page.on('error', error => {\n console.error(`[headless fault] ${error}`)\n console.error(error.stack)\n })\n\n page.on('requestfailed', request => {\n console.error('Request failed:', request.url())\n })\n\n page.on('request', request => {\n if (request.url() === url) {\n request.continue({\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n postData: JSON.stringify({\n fonts: fontsToUse,\n fontStyles\n })\n })\n } else {\n const headers = labelRequestHeaders(request.url(), `${protocol}://${host}:${port}`, jobs.get(page), request.headers())\n if (headers) request.continue({ headers })\n else request.continue()\n }\n })\n\n await page.goto(url, { timeout: 0, waitUntil: 'load' })\n\n return { browser, page }\n}\n\n/**\n * 라벨 출력은 (자동 출고 워크플로우 등) 즉시성 요구 — cold start 비용 (browser+page\n * setup ~2-3 초) 이 사용자 경험에 직결. min=1 로 최소 1 개 warm 유지가 기본.\n * 사용 빈도가 매우 낮은 사이트는 환경 변수로 0 까지 낮출 수 있다.\n *\n * HEADLESS_POOL_LABEL_MIN (default 1)\n * HEADLESS_POOL_LABEL_MAX (default 10)\n */\nconst LABEL_POOL_MIN = parseInt(process.env.HEADLESS_POOL_LABEL_MIN ?? '1', 10)\nconst LABEL_POOL_MAX = parseInt(process.env.HEADLESS_POOL_LABEL_MAX ?? '10', 10)\n\n/*\n * The pool is made when the server has started, not when this module is loaded.\n *\n * With min=1 the pool builds a browser as soon as it exists, and the page setup reads fonts from the database and\n * loads a page from this same server. At module load neither is ready: shell's databaseInitializer is still running\n * synchronize(), and on sqlite every query shares the one query runner. The fonts query's release() cleared the\n * runner's loaded-table list mid-synchronize, which then created env_vars again and died on 'already exists' —\n * plant did not start after 10.1.38 (seen 2026-09-24; it showed then because ops-master made synchronize rebuild\n * five tables, a longer window). 'bootstrap-module-start' comes after the database and after the server listens.\n *\n * A caller that needs a label before that — none should, since routes are served only after it — still gets a pool.\n */\nlet labelPool: ReturnType<typeof getOrCreateHeadlessPool> | undefined\n\nfunction pool() {\n labelPool ??= getOrCreateHeadlessPool('label', {\n min: LABEL_POOL_MIN,\n max: LABEL_POOL_MAX,\n args: [...HEADLESS_POOL_ARGUMENT_SETS.basic, ...HEADLESS_POOL_ARGUMENT_SETS.keychain_safe],\n acquireTimeoutMillis: 15000,\n testOnBorrow: true,\n enableCleanup: true,\n customSetup: setupLabelPage\n })\n return labelPool\n}\n\nonHook('bootstrap-module-start', () => {\n pool()\n})\n\n// Font change event subscription\nsetTimeout(async () => {\n const eventSource = pubsub.subscribe('notify-font-changed')\n for await (const data of eventSource) {\n // Reset the pool when fonts change; a pool not made yet will read the new fonts when it is.\n await labelPool?.reset()\n }\n})\n\n/**\n * Get the label headless pool\n * @returns Pool instance with acquire/release methods\n */\nexport function getHeadlessPool() {\n return {\n acquire: () => pool().acquire(),\n release: (resource: any) => pool().release(resource),\n size: 0, // These will be dynamically calculated if needed\n available: 0,\n borrowed: 0,\n pending: 0,\n max: LABEL_POOL_MAX,\n min: LABEL_POOL_MIN,\n clear: () => labelPool?.reset() // For font change compatibility\n }\n}"]}
|
|
@@ -49,4 +49,48 @@ test('clearing before the pool exists does nothing and does not make one', async
|
|
|
49
49
|
await getHeadlessPool().clear();
|
|
50
50
|
expect(created).toHaveLength(0);
|
|
51
51
|
});
|
|
52
|
+
/*
|
|
53
|
+
* ADR-0106 decision 4: the label page is shared by every domain, so a sub-request it makes to this server goes as the
|
|
54
|
+
* job's user and domain -- set per job, gone after it -- and a request to another host goes as it was.
|
|
55
|
+
*/
|
|
56
|
+
describe('a label render\'s sub-requests go as the job\'s user and domain', () => {
|
|
57
|
+
const origin = `http://localhost:${process.env.PORT}`;
|
|
58
|
+
function fakePage() {
|
|
59
|
+
const handlers = {};
|
|
60
|
+
const page = {
|
|
61
|
+
setRequestInterception: async () => { },
|
|
62
|
+
on: (event, handler) => (handlers[event] = handler),
|
|
63
|
+
goto: async () => { }
|
|
64
|
+
};
|
|
65
|
+
const request = (url) => {
|
|
66
|
+
const continued = [];
|
|
67
|
+
handlers.request({ url: () => url, headers: () => ({ accept: 'image/*' }), continue: (o) => continued.push(o) });
|
|
68
|
+
return continued[0];
|
|
69
|
+
};
|
|
70
|
+
return { page, request };
|
|
71
|
+
}
|
|
72
|
+
test('to this server during a job: the bearer and the domain; to another host, or between jobs: nothing', async () => {
|
|
73
|
+
const { setupLabelPage, forLabelJob } = load();
|
|
74
|
+
const fake = fakePage();
|
|
75
|
+
await setupLabelPage({ newPage: async () => fake.page });
|
|
76
|
+
const who = { domain: { subdomain: 'plant-a' }, user: { sign: async () => 'token-a' } };
|
|
77
|
+
await forLabelJob(fake.page, who, async () => {
|
|
78
|
+
expect(fake.request(`${origin}/attachment/abc.png`)).toEqual({ headers: { accept: 'image/*', 'x-things-factory-domain': 'plant-a', Authorization: 'Bearer token-a' } });
|
|
79
|
+
expect(fake.request('https://cdn.example.com/x.png')).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
expect(fake.request(`${origin}/attachment/abc.png`)).toBeUndefined();
|
|
82
|
+
/* The next job on the same page is another domain's: its own headers, none of the last one's. */
|
|
83
|
+
await forLabelJob(fake.page, { domain: { subdomain: 'plant-b' }, user: { sign: async () => 'token-b' } }, async () => {
|
|
84
|
+
expect(fake.request(`${origin}/attachment/abc.png`).headers['x-things-factory-domain']).toBe('plant-b');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
test('a host that only starts like this one is another host', () => {
|
|
88
|
+
const { labelRequestHeaders } = load();
|
|
89
|
+
expect(labelRequestHeaders(`${origin}.evil.com/x`, origin, { subdomain: 'a', token: 't' }, {})).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
test('the label command renders inside a job, as the user who asked in their domain', () => {
|
|
93
|
+
const source = require('fs').readFileSync(require('path').join(__dirname, 'label-command.ts'), 'utf8');
|
|
94
|
+
expect(source).toContain("await forLabelJob(page, { domain, user: opts.context.state.user }, () => page.evaluate(");
|
|
95
|
+
});
|
|
52
96
|
//# sourceMappingURL=headless-pool-for-label.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"headless-pool-for-label.test.js","sourceRoot":"","sources":["../../server/controllers/headless-pool-for-label.test.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The label pool is made after the server starts, not when the module loads.\n *\n * With min=1 a pool builds a browser as soon as it exists, and that setup reads fonts from the database. At module\n * load the database is still being synchronised; on sqlite that query shares synchronize()'s one query runner and\n * broke it — plant did not start after 10.1.38 (2026-09-24). 'bootstrap-module-start' follows the database.\n */\nconst created: any[] = []\nconst hooks: Record<string, Function[]> = {}\n\njest.mock('@things-factory/shell', () => ({\n HEADLESS_POOL_ARGUMENT_SETS: { basic: [], keychain_safe: [] },\n getOrCreateHeadlessPool: jest.fn((name: string, config: any) => {\n const pool = { name, config, acquire: jest.fn(async () => 'page'), release: jest.fn(), reset: jest.fn() }\n created.push(pool)\n return pool\n }),\n onHook: (name: string, handler: Function) => (hooks[name] ??= []).push(handler),\n pubsub: { subscribe: () => ({ [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) }) }) }\n}))\njest.mock('./fonts', () => ({ fonts: jest.fn(async () => [[], '']) }))\n\nbeforeEach(() => {\n jest.resetModules()\n created.length = 0\n for (const key of Object.keys(hooks)) delete hooks[key]\n})\n\nconst load = () => require('./headless-pool-for-label') as typeof import('./headless-pool-for-label')\n\ntest('loading the module makes no pool — nothing touches the database while it is being synchronised', () => {\n load()\n expect(created).toHaveLength(0)\n})\n\ntest('the pool is made when the server has started', () => {\n load()\n expect(hooks['bootstrap-module-start']).toHaveLength(1)\n\n hooks['bootstrap-module-start'][0]({})\n\n expect(created).toHaveLength(1)\n expect(created[0].name).toBe('label')\n})\n\ntest('one pool, however it is first reached', async () => {\n const { getHeadlessPool } = load()\n\n await getHeadlessPool().acquire()\n hooks['bootstrap-module-start'][0]({})\n await getHeadlessPool().acquire()\n\n expect(created).toHaveLength(1)\n expect(created[0].acquire).toHaveBeenCalledTimes(2)\n})\n\ntest('clearing before the pool exists does nothing and does not make one', async () => {\n const { getHeadlessPool } = load()\n await getHeadlessPool().clear()\n expect(created).toHaveLength(0)\n})\n"]}
|
|
1
|
+
{"version":3,"file":"headless-pool-for-label.test.js","sourceRoot":"","sources":["../../server/controllers/headless-pool-for-label.test.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The label pool is made after the server starts, not when the module loads.\n *\n * With min=1 a pool builds a browser as soon as it exists, and that setup reads fonts from the database. At module\n * load the database is still being synchronised; on sqlite that query shares synchronize()'s one query runner and\n * broke it — plant did not start after 10.1.38 (2026-09-24). 'bootstrap-module-start' follows the database.\n */\nconst created: any[] = []\nconst hooks: Record<string, Function[]> = {}\n\njest.mock('@things-factory/shell', () => ({\n HEADLESS_POOL_ARGUMENT_SETS: { basic: [], keychain_safe: [] },\n getOrCreateHeadlessPool: jest.fn((name: string, config: any) => {\n const pool = { name, config, acquire: jest.fn(async () => 'page'), release: jest.fn(), reset: jest.fn() }\n created.push(pool)\n return pool\n }),\n onHook: (name: string, handler: Function) => (hooks[name] ??= []).push(handler),\n pubsub: { subscribe: () => ({ [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) }) }) }\n}))\njest.mock('./fonts', () => ({ fonts: jest.fn(async () => [[], '']) }))\n\nbeforeEach(() => {\n jest.resetModules()\n created.length = 0\n for (const key of Object.keys(hooks)) delete hooks[key]\n})\n\nconst load = () => require('./headless-pool-for-label') as typeof import('./headless-pool-for-label')\n\ntest('loading the module makes no pool — nothing touches the database while it is being synchronised', () => {\n load()\n expect(created).toHaveLength(0)\n})\n\ntest('the pool is made when the server has started', () => {\n load()\n expect(hooks['bootstrap-module-start']).toHaveLength(1)\n\n hooks['bootstrap-module-start'][0]({})\n\n expect(created).toHaveLength(1)\n expect(created[0].name).toBe('label')\n})\n\ntest('one pool, however it is first reached', async () => {\n const { getHeadlessPool } = load()\n\n await getHeadlessPool().acquire()\n hooks['bootstrap-module-start'][0]({})\n await getHeadlessPool().acquire()\n\n expect(created).toHaveLength(1)\n expect(created[0].acquire).toHaveBeenCalledTimes(2)\n})\n\ntest('clearing before the pool exists does nothing and does not make one', async () => {\n const { getHeadlessPool } = load()\n await getHeadlessPool().clear()\n expect(created).toHaveLength(0)\n})\n\n/*\n * ADR-0106 decision 4: the label page is shared by every domain, so a sub-request it makes to this server goes as the\n * job's user and domain -- set per job, gone after it -- and a request to another host goes as it was.\n */\ndescribe('a label render\\'s sub-requests go as the job\\'s user and domain', () => {\n const origin = `http://localhost:${process.env.PORT}`\n function fakePage() {\n const handlers: Record<string, Function> = {}\n const page = {\n setRequestInterception: async () => {},\n on: (event: string, handler: Function) => (handlers[event] = handler),\n goto: async () => {}\n }\n const request = (url: string) => {\n const continued: any[] = []\n handlers.request({ url: () => url, headers: () => ({ accept: 'image/*' }), continue: (o?: any) => continued.push(o) })\n return continued[0]\n }\n return { page, request }\n }\n\n test('to this server during a job: the bearer and the domain; to another host, or between jobs: nothing', async () => {\n const { setupLabelPage, forLabelJob } = load()\n const fake = fakePage()\n await setupLabelPage({ newPage: async () => fake.page })\n const who = { domain: { subdomain: 'plant-a' }, user: { sign: async () => 'token-a' } }\n await forLabelJob(fake.page, who, async () => {\n expect(fake.request(`${origin}/attachment/abc.png`)).toEqual({ headers: { accept: 'image/*', 'x-things-factory-domain': 'plant-a', Authorization: 'Bearer token-a' } })\n expect(fake.request('https://cdn.example.com/x.png')).toBeUndefined()\n })\n expect(fake.request(`${origin}/attachment/abc.png`)).toBeUndefined()\n /* The next job on the same page is another domain's: its own headers, none of the last one's. */\n await forLabelJob(fake.page, { domain: { subdomain: 'plant-b' }, user: { sign: async () => 'token-b' } }, async () => {\n expect(fake.request(`${origin}/attachment/abc.png`).headers['x-things-factory-domain']).toBe('plant-b')\n })\n })\n\n test('a host that only starts like this one is another host', () => {\n const { labelRequestHeaders } = load()\n expect(labelRequestHeaders(`${origin}.evil.com/x`, origin, { subdomain: 'a', token: 't' }, {})).toBeUndefined()\n })\n})\n\ntest('the label command renders inside a job, as the user who asked in their domain', () => {\n const source = require('fs').readFileSync(require('path').join(__dirname, 'label-command.ts'), 'utf8')\n expect(source).toContain(\"await forLabelJob(page, { domain, user: opts.context.state.user }, () => page.evaluate(\")\n})\n"]}
|
|
@@ -118,7 +118,8 @@ async function buildLabelGrf(opts) {
|
|
|
118
118
|
try {
|
|
119
119
|
const { page } = browser;
|
|
120
120
|
const { model: resolvedModel } = await (0, headless_model_js_1.headlessModel)({ domain, model: opts.model, id: opts.id }, !!opts.draft);
|
|
121
|
-
|
|
121
|
+
/* The page is shared by every domain: what it fetches meanwhile goes as this request's user and domain. */
|
|
122
|
+
const grf = await (0, headless_pool_for_label_js_1.forLabelJob)(page, { domain, user: opts.context.state.user }, () => page.evaluate(async (model, data, orientation, mirror, upsideDown) => {
|
|
122
123
|
//@ts-ignore
|
|
123
124
|
let s = createScene(model);
|
|
124
125
|
if (data) {
|
|
@@ -135,7 +136,7 @@ async function buildLabelGrf(opts) {
|
|
|
135
136
|
s.dispose();
|
|
136
137
|
});
|
|
137
138
|
});
|
|
138
|
-
}, resolvedModel, opts.data, opts.orientation, opts.mirror, opts.upsideDown);
|
|
139
|
+
}, resolvedModel, opts.data, opts.orientation, opts.mirror, opts.upsideDown));
|
|
139
140
|
return `
|
|
140
141
|
^XA
|
|
141
142
|
^GFA,${grf}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"label-command.js","sourceRoot":"","sources":["../../server/controllers/label-command.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n *\n * Label command (ZPL/GRF) — 백엔드 헤드리스 *전용*. 클라이언트 캡처는 anti-aliasing\n * / sub-pixel / DPR 차이로 ZPL 1-bit 정확 비트맵과 호환 안 됨. 또한 자동 출고\n * 워크플로우는 클라이언트 없이 트리거되므로 헤드리스 외 옵션 없음.\n *\n * 비용을 줄이는 두 메커니즘:\n * 1) In-flight dedup: 동일 (template, data, orientation, mirror, upsideDown)\n * 요청이 진행 중이면 같은 Promise 를 공유 — 라벨 출력 큐에서 같은 라벨이\n * 동시 N 회 요청되어도 puppeteer evaluate 는 1 회.\n * 2) LRU GRF 캐시: 같은 입력에 대해 TTL 동안 GRF 재사용 — 대량 인쇄에서\n * 같은 라벨 연속 출력 시 puppeteer 호출 0 회.\n */\n\nimport { createHash } from 'crypto'\n\nimport { getHeadlessPool } from './headless-pool-for-label.js'\nimport { headlessModel } from './headless-model.js'\n\nexport interface LabelCommandOptions {\n id?: string\n model?: any\n data?: any\n orientation?: string\n mirror?: boolean\n upsideDown?: boolean\n context: any\n draft?: boolean\n}\n\n// ── 캐시 / dedup state ─────────────────────────────────────────────────────\n\nconst GRF_CACHE_MAX = 200 // 최근 200 개 라벨 유지\nconst GRF_CACHE_TTL_MS = 60 * 1000 // 60 초 — id-기반 board update 도 1 분 내 반영\n\ninterface CacheEntry { value: string; expiresAt: number }\nconst grfCache = new Map<string, CacheEntry>()\nconst inFlight = new Map<string, Promise<string | undefined>>()\n\nexport function fnv1a(s: string): string {\n let hash = 2166136261\n for (let i = 0; i < s.length; i++) {\n hash ^= s.charCodeAt(i)\n hash = Math.imul(hash, 16777619) >>> 0\n }\n return hash.toString(16)\n}\n\n/**\n * **캐시 키가 담는 내용의 지문.** 32 비트 hash 로는 부족한 자리다.\n *\n * `fnv1a` 는 32 비트라 서로 다른 두 값이 같은 숫자를 낸다. 캐시 키에서 그것은 곧\n * **다른 라벨이 인쇄되는 것**이다 — 값이 다른데 키가 같으면 앞서 만든 라벨이 그대로 나간다.\n * 지어낸 이야기가 아니라 짧은 SKU 두 개로 실제로 난다(`{\"sku\":\"967533\"}` 와 `{\"sku\":\"1488000\"}` 이\n * 둘 다 `97501f3d` 다 — 그 시험이 이 파일 곁에 있다).\n *\n * 키는 메모리 안에만 있으므로 길이가 문제되지 않는다. `fnv1a` 자체는 썸네일 스케줄러가 쓰고 있어\n * 그대로 두되, **캐시 키에서는 쓰지 않는다.**\n */\nfunction digest(s: string): string {\n return createHash('sha256').update(s).digest('hex')\n}\n\nexport function cacheKey(opts: LabelCommandOptions): string {\n /**\n * **키의 첫 칸은 누가 물었는가다** (2026-09-22).\n *\n * 이것이 없으면 캐시가 테넌트를 넘는다. `labelcommand` 는 캐시를 **가장 먼저** 보고, 도메인을 보는\n * 것은 그 뒤의 `buildLabelGrf` 뿐이다(`headlessModel` 이 `In([domain.id, domain.parentId])` 로 찾는다).\n * 그래서 다른 테넌트가 같은 보드 id 로 물으면 **거절이어야 할 답이 앞 사람의 라벨로 나갔다.**\n *\n * 자식 도메인이 부모의 보드를 읽는 것은 정당하므로 도메인 하나로는 모자란다. 키는 **풀린 도메인\n * 집합**을 담는다 — 같은 부모를 둔 자식 둘도 서로의 라벨을 받지 않는다.\n */\n const domain = opts.context?.state?.domain\n const tenant = `t:${domain?.id ?? ''}>${domain?.parentId ?? ''}`\n // id 기반은 board update 가 있을 수 있으므로 TTL 짧게.\n // model 직접 전달은 model 자체 hash — 변경 시 자동으로 새 key.\n const tpl = opts.id\n ? `id:${opts.id}:${opts.draft ? 'd' : 'r'}`\n : `m:${digest(typeof opts.model === 'string' ? opts.model : JSON.stringify(opts.model ?? null))}`\n const dataKey = opts.data ? digest(JSON.stringify(opts.data)) : '_'\n return `${tenant}|${tpl}|${dataKey}|${opts.orientation ?? ''}|${opts.mirror ? 1 : 0}|${opts.upsideDown ? 1 : 0}`\n}\n\nfunction cacheGet(key: string): string | undefined {\n const entry = grfCache.get(key)\n if (!entry) return undefined\n if (entry.expiresAt < Date.now()) {\n grfCache.delete(key)\n return undefined\n }\n // LRU refresh — 최근 사용을 Map 마지막으로 이동.\n grfCache.delete(key)\n grfCache.set(key, entry)\n return entry.value\n}\n\nfunction cacheSet(key: string, value: string): void {\n grfCache.delete(key)\n grfCache.set(key, { value, expiresAt: Date.now() + GRF_CACHE_TTL_MS })\n if (grfCache.size > GRF_CACHE_MAX) {\n const oldest = grfCache.keys().next().value\n if (oldest !== undefined) grfCache.delete(oldest)\n }\n}\n\n/** 진단 / 운영용 — 현재 cache hit ratio 등 모니터링 가능. */\nexport function getLabelCacheStats() {\n return { cacheSize: grfCache.size, inFlight: inFlight.size }\n}\n\n/** Test-only — 캐시 / in-flight state 초기화. */\nexport function _resetLabelState(): void {\n grfCache.clear()\n inFlight.clear()\n}\n\n/**\n * Test-only — buildLabelGrf 를 mock 으로 교체. production 에서는 항상 default 사용.\n */\nlet _buildOverride: ((opts: LabelCommandOptions) => Promise<string | undefined>) | null = null\nexport function _setBuildOverride(fn: ((opts: LabelCommandOptions) => Promise<string | undefined>) | null): void {\n _buildOverride = fn\n}\n\n// ── 헤드리스 evaluate (실제 puppeteer 호출). ──────────────────────────────────\n\nasync function buildLabelGrf(opts: LabelCommandOptions): Promise<string | undefined> {\n const { domain } = opts.context.state\n\n const browser = (await getHeadlessPool().acquire()) as any\n if (!browser) return undefined\n\n try {\n const { page } = browser\n\n const { model: resolvedModel } = await headlessModel(\n { domain, model: opts.model, id: opts.id },\n !!opts.draft\n )\n\n const grf = await page.evaluate(\n async (model, data, orientation, mirror, upsideDown) => {\n //@ts-ignore\n let s = createScene(model)\n if (data) {\n s.data = data\n }\n return new Promise(resolve => {\n // @ts-ignore\n requestAnimationFrame(() => {\n // @ts-ignore\n let grf = imageDataToGrf(s, model, orientation, mirror, upsideDown)\n resolve(grf)\n // @ts-ignore\n sceneContainer.removeChild(s.target)\n s.dispose()\n })\n })\n },\n resolvedModel,\n opts.data,\n opts.orientation,\n opts.mirror,\n opts.upsideDown\n )\n\n return `\n^XA\n^GFA,${grf}\n^FS\n^XZ`\n } finally {\n // 에러 발생 시에도 반드시 풀에 브라우저를 반환\n getHeadlessPool().release(browser)\n }\n}\n\n/**\n * 라벨 출력 — ZPL/GRF 문자열 반환.\n *\n * @param {String} id 모델 ID\n * @param {Object} data 매핑할 데이터\n * @param {String} orientation (시계방향) N: 0, R: 90, I: 180, B: 270\n * @param {boolean} mirror 좌우반전\n * @param {boolean} upsideDown 상하반전\n */\nexport const labelcommand = async (opts: LabelCommandOptions): Promise<string | undefined> => {\n const key = cacheKey(opts)\n\n // 1) GRF LRU 캐시 — 같은 입력 반복 출력 시 puppeteer 호출 0.\n const cached = cacheGet(key)\n if (cached !== undefined) return cached\n\n // 2) In-flight dedup — 동일 요청이 이미 진행 중이면 같은 Promise share.\n const existing = inFlight.get(key)\n if (existing) return existing\n\n const builder = _buildOverride ?? buildLabelGrf\n const promise = builder(opts)\n .then(result => {\n if (result) cacheSet(key, result)\n return result\n })\n .finally(() => {\n inFlight.delete(key)\n })\n\n inFlight.set(key, promise)\n return promise\n}\n"]}
|
|
1
|
+
{"version":3,"file":"label-command.js","sourceRoot":"","sources":["../../server/controllers/label-command.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n *\n * Label command (ZPL/GRF) — 백엔드 헤드리스 *전용*. 클라이언트 캡처는 anti-aliasing\n * / sub-pixel / DPR 차이로 ZPL 1-bit 정확 비트맵과 호환 안 됨. 또한 자동 출고\n * 워크플로우는 클라이언트 없이 트리거되므로 헤드리스 외 옵션 없음.\n *\n * 비용을 줄이는 두 메커니즘:\n * 1) In-flight dedup: 동일 (template, data, orientation, mirror, upsideDown)\n * 요청이 진행 중이면 같은 Promise 를 공유 — 라벨 출력 큐에서 같은 라벨이\n * 동시 N 회 요청되어도 puppeteer evaluate 는 1 회.\n * 2) LRU GRF 캐시: 같은 입력에 대해 TTL 동안 GRF 재사용 — 대량 인쇄에서\n * 같은 라벨 연속 출력 시 puppeteer 호출 0 회.\n */\n\nimport { createHash } from 'crypto'\n\nimport { forLabelJob, getHeadlessPool } from './headless-pool-for-label.js'\nimport { headlessModel } from './headless-model.js'\n\nexport interface LabelCommandOptions {\n id?: string\n model?: any\n data?: any\n orientation?: string\n mirror?: boolean\n upsideDown?: boolean\n context: any\n draft?: boolean\n}\n\n// ── 캐시 / dedup state ─────────────────────────────────────────────────────\n\nconst GRF_CACHE_MAX = 200 // 최근 200 개 라벨 유지\nconst GRF_CACHE_TTL_MS = 60 * 1000 // 60 초 — id-기반 board update 도 1 분 내 반영\n\ninterface CacheEntry { value: string; expiresAt: number }\nconst grfCache = new Map<string, CacheEntry>()\nconst inFlight = new Map<string, Promise<string | undefined>>()\n\nexport function fnv1a(s: string): string {\n let hash = 2166136261\n for (let i = 0; i < s.length; i++) {\n hash ^= s.charCodeAt(i)\n hash = Math.imul(hash, 16777619) >>> 0\n }\n return hash.toString(16)\n}\n\n/**\n * **캐시 키가 담는 내용의 지문.** 32 비트 hash 로는 부족한 자리다.\n *\n * `fnv1a` 는 32 비트라 서로 다른 두 값이 같은 숫자를 낸다. 캐시 키에서 그것은 곧\n * **다른 라벨이 인쇄되는 것**이다 — 값이 다른데 키가 같으면 앞서 만든 라벨이 그대로 나간다.\n * 지어낸 이야기가 아니라 짧은 SKU 두 개로 실제로 난다(`{\"sku\":\"967533\"}` 와 `{\"sku\":\"1488000\"}` 이\n * 둘 다 `97501f3d` 다 — 그 시험이 이 파일 곁에 있다).\n *\n * 키는 메모리 안에만 있으므로 길이가 문제되지 않는다. `fnv1a` 자체는 썸네일 스케줄러가 쓰고 있어\n * 그대로 두되, **캐시 키에서는 쓰지 않는다.**\n */\nfunction digest(s: string): string {\n return createHash('sha256').update(s).digest('hex')\n}\n\nexport function cacheKey(opts: LabelCommandOptions): string {\n /**\n * **키의 첫 칸은 누가 물었는가다** (2026-09-22).\n *\n * 이것이 없으면 캐시가 테넌트를 넘는다. `labelcommand` 는 캐시를 **가장 먼저** 보고, 도메인을 보는\n * 것은 그 뒤의 `buildLabelGrf` 뿐이다(`headlessModel` 이 `In([domain.id, domain.parentId])` 로 찾는다).\n * 그래서 다른 테넌트가 같은 보드 id 로 물으면 **거절이어야 할 답이 앞 사람의 라벨로 나갔다.**\n *\n * 자식 도메인이 부모의 보드를 읽는 것은 정당하므로 도메인 하나로는 모자란다. 키는 **풀린 도메인\n * 집합**을 담는다 — 같은 부모를 둔 자식 둘도 서로의 라벨을 받지 않는다.\n */\n const domain = opts.context?.state?.domain\n const tenant = `t:${domain?.id ?? ''}>${domain?.parentId ?? ''}`\n // id 기반은 board update 가 있을 수 있으므로 TTL 짧게.\n // model 직접 전달은 model 자체 hash — 변경 시 자동으로 새 key.\n const tpl = opts.id\n ? `id:${opts.id}:${opts.draft ? 'd' : 'r'}`\n : `m:${digest(typeof opts.model === 'string' ? opts.model : JSON.stringify(opts.model ?? null))}`\n const dataKey = opts.data ? digest(JSON.stringify(opts.data)) : '_'\n return `${tenant}|${tpl}|${dataKey}|${opts.orientation ?? ''}|${opts.mirror ? 1 : 0}|${opts.upsideDown ? 1 : 0}`\n}\n\nfunction cacheGet(key: string): string | undefined {\n const entry = grfCache.get(key)\n if (!entry) return undefined\n if (entry.expiresAt < Date.now()) {\n grfCache.delete(key)\n return undefined\n }\n // LRU refresh — 최근 사용을 Map 마지막으로 이동.\n grfCache.delete(key)\n grfCache.set(key, entry)\n return entry.value\n}\n\nfunction cacheSet(key: string, value: string): void {\n grfCache.delete(key)\n grfCache.set(key, { value, expiresAt: Date.now() + GRF_CACHE_TTL_MS })\n if (grfCache.size > GRF_CACHE_MAX) {\n const oldest = grfCache.keys().next().value\n if (oldest !== undefined) grfCache.delete(oldest)\n }\n}\n\n/** 진단 / 운영용 — 현재 cache hit ratio 등 모니터링 가능. */\nexport function getLabelCacheStats() {\n return { cacheSize: grfCache.size, inFlight: inFlight.size }\n}\n\n/** Test-only — 캐시 / in-flight state 초기화. */\nexport function _resetLabelState(): void {\n grfCache.clear()\n inFlight.clear()\n}\n\n/**\n * Test-only — buildLabelGrf 를 mock 으로 교체. production 에서는 항상 default 사용.\n */\nlet _buildOverride: ((opts: LabelCommandOptions) => Promise<string | undefined>) | null = null\nexport function _setBuildOverride(fn: ((opts: LabelCommandOptions) => Promise<string | undefined>) | null): void {\n _buildOverride = fn\n}\n\n// ── 헤드리스 evaluate (실제 puppeteer 호출). ──────────────────────────────────\n\nasync function buildLabelGrf(opts: LabelCommandOptions): Promise<string | undefined> {\n const { domain } = opts.context.state\n\n const browser = (await getHeadlessPool().acquire()) as any\n if (!browser) return undefined\n\n try {\n const { page } = browser\n\n const { model: resolvedModel } = await headlessModel(\n { domain, model: opts.model, id: opts.id },\n !!opts.draft\n )\n\n /* The page is shared by every domain: what it fetches meanwhile goes as this request's user and domain. */\n const grf = await forLabelJob(page, { domain, user: opts.context.state.user }, () => page.evaluate(\n async (model, data, orientation, mirror, upsideDown) => {\n //@ts-ignore\n let s = createScene(model)\n if (data) {\n s.data = data\n }\n return new Promise(resolve => {\n // @ts-ignore\n requestAnimationFrame(() => {\n // @ts-ignore\n let grf = imageDataToGrf(s, model, orientation, mirror, upsideDown)\n resolve(grf)\n // @ts-ignore\n sceneContainer.removeChild(s.target)\n s.dispose()\n })\n })\n },\n resolvedModel,\n opts.data,\n opts.orientation,\n opts.mirror,\n opts.upsideDown\n ))\n\n return `\n^XA\n^GFA,${grf}\n^FS\n^XZ`\n } finally {\n // 에러 발생 시에도 반드시 풀에 브라우저를 반환\n getHeadlessPool().release(browser)\n }\n}\n\n/**\n * 라벨 출력 — ZPL/GRF 문자열 반환.\n *\n * @param {String} id 모델 ID\n * @param {Object} data 매핑할 데이터\n * @param {String} orientation (시계방향) N: 0, R: 90, I: 180, B: 270\n * @param {boolean} mirror 좌우반전\n * @param {boolean} upsideDown 상하반전\n */\nexport const labelcommand = async (opts: LabelCommandOptions): Promise<string | undefined> => {\n const key = cacheKey(opts)\n\n // 1) GRF LRU 캐시 — 같은 입력 반복 출력 시 puppeteer 호출 0.\n const cached = cacheGet(key)\n if (cached !== undefined) return cached\n\n // 2) In-flight dedup — 동일 요청이 이미 진행 중이면 같은 Promise share.\n const existing = inFlight.get(key)\n if (existing) return existing\n\n const builder = _buildOverride ?? buildLabelGrf\n const promise = builder(opts)\n .then(result => {\n if (result) cacheSet(key, result)\n return result\n })\n .finally(() => {\n inFlight.delete(key)\n })\n\n inFlight.set(key, promise)\n return promise\n}\n"]}
|